Documentation
- 1: util
- 1.1: git
- 1.1.1: github-actions发布pages
- 1.1.2: git常用操作
- 1.1.3: git基础知识
- 1.2: gitbook使用指南
- 1.3: github搜索技巧
- 1.4: 快捷键
- 1.5: hugo搭建个人网站
- 1.6: 正则表达式
- 2: OS
- 2.1: 00.OS相关概念
- 2.2: Linux负载均衡LVS之IPVS
- 2.3: shell
- 2.3.1: Shebang
- 2.3.2: curl&wget
- 2.3.3: shell-find
- 2.3.4: shell文本处理三剑客
- 2.3.5: shell解析json
- 2.3.6: 获取程序当前路径有多麻烦
- 2.4: 进程
- 2.4.1: Linux后台进程
- 2.4.2: Linux的启动过程
- 2.4.3: supervisor
- 2.4.4: systemd
- 2.4.5: goreman
- 2.4.6: 守护进程
- 2.4.7: 进程间通信
- 2.5: 文件系统
- 2.5.1: Linux文件系统-01设备
- 3: NetWork
- 4: IO
- 4.1: Java NIO核心知识
- 4.2: Netty服务器启动源码分析
- 4.3: IO模型
- 5: Java
- 5.1: 基础
- 5.1.1: 双亲委派机制
- 5.2: JVM
- 5.3: 并发
- 5.3.1: Java并发编程的艺术
- 5.4: Web
- 5.5: 框架
- 5.5.1: Mybatis
- 5.5.2: Spring
- 5.5.2.1: 01.Spring基础知识
- 5.5.2.2: 04.Spring定时任务
- 5.5.2.3: 12.Spring扩展
- 5.5.2.4: 50.Spring循环依赖
- 5.5.2.5: 51.SpringBean的加载源码
- 5.5.2.6: spring注解驱动+源码
- 5.5.2.7: SpringBoot-01基础使用及原理
- 5.5.2.8: SpringBoot-02Web开发及请求响应处理
- 5.5.2.9: SpringBoot-03Web组件
- 5.5.2.10: SpringBoot-04数据访问
- 5.5.2.11: SpringBoot-05单元测试
- 5.5.2.12: SpringBoot-06指标监控
- 5.5.2.13: SpringBoot-07配置与加载过程
- 5.5.3: SpringMVC
- 5.6: 第三方包
- 5.6.1: log
- 5.6.2: lombok功能及原理
- 6: Golang
- 6.1: go环境搭建及语法
- 6.2: Go语言基础知识01
- 6.3: Go语言基础知识02
- 6.4: util-cli
- 6.5: mod包冲突解决实战01
- 7: Python
- 7.1: pydemo
- 7.2: selenium使用
- 8: Cpp
- 8.1: cpp入门-01基础语法
- 9: 数据库
- 9.1: 分布式数据库理论概述
- 9.2: MySQL
- 9.2.1: 01.SQL查询流程
- 9.2.2: 02.日志
- 9.2.3: 02.日志扩展-主从架构
- 9.2.4: 03.表
- 9.2.5: 04.索引
- 9.2.6: 05.锁
- 9.2.7: 06.事务隔离
- 9.2.8: MySQL技术内幕-InnoDB
- 9.3: Redis
- 9.3.1: Redis开发与运维
- 9.4: TiDB
- 9.4.1: TiDB初探
- 9.5: SQL引擎
- 9.5.1: 01.SQL解析器介绍
- 9.5.2: 02.Join操作
- 9.5.3: 03.统计计数
- 9.5.4: Presto简介
- 9.6: PostgreSQL
- 9.6.1: PostgreSQL基础使用入门
- 9.7: 图
- 9.7.1: 图数据库基本介绍
- 9.8: LevelDB
- 9.8.1: LevelDB-01基本介绍
- 9.8.2: LevelDB-02基础数据结构
- 9.8.3: LevelDB-03Log
- 9.8.4: LevelDB-04数据读写
- 9.8.5: LevelDB-05Cache
- 9.8.6: LevelDB-06Compaction
- 9.9: ClickHouse
- 9.9.1: 01.ClickHouse基本介绍
- 10: 存储
- 11: 分布式
- 11.1: Zookeeper基本使用
- 11.2: zookeeper启动源码分析
- 11.3: 分布式一致性算法
- 11.4: Quorum机制
- 11.5: Raft-01.基础理论
- 11.6: Raft-02.raftexample源码分析
- 11.7: 分布式锁实现及方案比较
- 11.8: 常见分布式拓扑结构
- 12: RPC
- 12.1: RPC不同实现方式
- 13: MQ
- 13.1: 深入理解Kafka:核心设计与实践原理
- 13.2: Pulsar
- 13.3: RocketMQ源码分析
- 14: 搜索
- 14.1: ES和Solr比较
- 14.2: ES安装与使用
- 14.3: ES基本操作
- 14.4: ES基本操作-分词器
- 14.5: ES基本操作整合SpringBoot
- 14.6: 深入理解ElasticSearch
- 14.6.1: 深入理解ES-01简介
- 14.6.2: 深入理解ES-02查询DSL进阶
- 14.6.3: 深入理解ES-03底层索引控制
- 14.6.4: 深入理解ES-04分布式索引架构
- 14.6.5: 深入理解ES-05管理ES
- 14.6.6: 深入理解ES-06故障处理
- 14.6.7: 深入理解ES-07改善用户搜索体验
- 14.6.8: 深入理解ES-08 ES Java API
- 14.6.9: 深入理解ES-09开发ES插件
- 15: APM
- 15.1: Skywalking基础使用
- 15.2: Skywalking源码分析2-启动流程
- 15.3: 字节码增强技术-概念
- 16: Container
- 16.1: 01.docker基础版
- 16.2: 20.docker目录
- 16.3: 21.docker路径修改
- 16.4: 22.dockerd
- 16.5: 51.kubernetes01
- 16.6: 52.kubernetes02
- 17: Cloud
- 18: 安全
- 18.1: fastjson远程执行漏洞分析
- 18.2: Log4j2远程执行漏洞
- 19: 大数据
- 19.1: 初探Hbase
- 19.2: 大数据框架
- 19.3: HDFS-01基本介绍
- 19.4: HDFS-02基本命令
- 19.5: Spark
- 19.5.1: 01.Spark基本介绍
- 20: 算法与数据结构
- 21: 前端
- 21.1: Vue
- 21.1.1: js封装函数及axios封装
- 21.1.2: vue保存时上传图片
- 21.1.3: vue错误集锦
- 21.1.4: vue结合后台接口配置调试环境
- 21.1.5: vue快速入门
- 21.1.6: vue显示数值小数点
- 22: 通用
- 22.1: cron表达式
- 22.2: 架构设计01-基础架构
- 22.3: 架构设计02-高性能架构模式
- 22.4: 架构设计03-高可用架构模式
- 22.5: 架构设计04-可扩展架构模式
- 22.6: 架构设计05-架构实战
- 22.7: 架构设计06-实践
- 23: 读书笔记
- 24: 生活常识
- 24.1: 急救知识
1 - util
1.1 - git
1.1.1 - github-actions发布pages
生成 token
参考官方文档 Creating a personal access token
- Profile -> Settings -> Developer settings
- 选择 Personal access tokens
- 仅选择 repo 权限
- 复制 token,只显示一次
配置 token
- 在仓库的 settings -> Secrets 新建
- token名称任意,这里为 ACCESS_TOKEN ,后面需要用到
准备代码
- 在当前需要部署的仓库根目录下新建文件夹
.github/workflows
- 新建文件 gitbook-cli.yml ,文件名任意,后缀固定
Expand/Collapse Code Block
| |
提交代码
将仓库内代码提交到分支即可,观察仓库Actions
Reference
1.1.2 - git常用操作
基本操作
用git提交代码到远程库
a.将目录切换到mobile-oss-web。cd mobile-oss-web (mac上使用命令行)
b.将修改全部提交到暂存区域。git add . //add . 表示添加全部修改。(mac上使用git命令)
c.将修改提交到当前分支。git commit -m“test” //-m后的“”里面是备注。 (mac上使用git命令)
d.将代码提交到远程库。git push origin master (mac上使用git命令)
-am只是将添加到git管理的文件提交变更,与add . 和commit有区别。
查看分支
| |
删除分支
| |
拉最新代码
Expand/Collapse Code Block
| |
拉远程分支
| |
beta环境合并步骤
1、新建Task获取编号
a、主题:mapi-index-service下线
b、详情:mapi-index-service下线 [http://aladdin.sankuai.com/#/task/appkey/2299](http://aladdin.sankuai.com/#/task/appkey/2299)
c、分支:master
d、负责人:zhangsan
2、新建本地分支
3、提交代码到远程编号分支
4、线下发布->CI->Ligh Merge->new LightMerge
a、Base: Master; Merge(Source Branches): 【编号】
b、CI branch:lightmerge
c、Submit...
d、Check...
e、Refresh and push
f、构建 -> 部署
线上环境/rc
1、Pullrequests
2、等待merge
3、打包
a、New Package
b、Tag: T2661-20190528 Branch:master 上线说明:=============上线周知=============
【修改内容】更换必吃菜接口依赖,支持2019必吃菜自动切榜
【影响范围】菜品spu功能
【操作】发布poi-sku-service
【负责人】xxxxx
【结对检查】xxxxx
【QA】自测
master回滚
回滚前先将git仓库所有权改成自己,关闭拒绝强制提交
Expand/Collapse Code Block
| |
git全局配置
修改git提交代码名称:
| |
多账号配置
参考:https://www.jianshu.com/p/89cb26e5c3e8 https://www.cnblogs.com/TRHX/p/11699999.html?ivk_sa=1024320u
配置~/.ssh/config
| |
设置name和email
| |
clone
| |
报错ip节点不在known_hosts中
错误:
| |
解决:
| |
没有权限
| |
建仓库

git remote set-url origin ssh://git@github.com:chnherb/chnherb.github.io.git

| |
切换/绑定仓库
git remote set-url origin ssh://git@github.com:chnherb/chnherb.github.io.git
gerrit
push代码到master
| |
报错分析
分支提交到远程分支然后
| |
报错原因: change id已经被使用
https://merrier.wang/20170820/what-is-change-id-in-git-commit.html
附没有change id:
https://www.cnblogs.com/yzhihao/p/8392704.html
合并dev冲突解决办法

tag
github tag流水线配置参考:https://github.com/chnherb/util-cli/blob/master/.github/workflows/release.yml
查看
| |
创建
| |
推送
| |
删除
| |
1.1.3 - git基础知识
1、git结构

- Workspace:工作区
- Index / Stage:暂存区
- Repository:仓库区(或本地仓库)
- Remote:远程仓库
2、git使用
2.1 创建git本地库
新建一个目录,将该目录转化为git本地仓库:
| |
2.2 向仓库提交文件
新建一个readme.txt文件,分两步添加该文件到Git仓库:
| |
2.3 版本回退
查看历史提交的修改:
| |
commit后的一串字符是每次commit的id,用于检索每次提交的版本 版本回退有revert和reset两种方案:
使用revert命令进行版本回退,只能回退最近一次修改版本,否则可能需要进行冲突处理,其原理是用一个相反的提交来回滚指定版本所做的修改:
| |
reset是一种重置,即错误提交了,此时要删除这个提交记录。也可以实现回滚:
| |
revert和reset的比较:
1、若以上修改已经push到线上代码库, reset 删除指定commit以后,git push可能导致一大堆冲突.但是revert 并不会.
2、如果在日后现有分支和历史分支需要合并的时候,reset 恢复部分的代码依然会出现在历史分支里.但是revert 方向提交的commit 并不会出现在历史分支里.
3、reset 是在正常的commit历史中,删除了指定的commit,这时 HEAD 是向后移动了,而 revert 是在正常的commit历史中再commit一次,只不过是反向提交,他的 HEAD 是一直向前的.
2.4 文件修改
| |
2.5 分支操作
创建新分支:
| |
以上带*的表示当前所处的分支 合并分支:
| |
分支冲突处理: 当要合并的分支中有内容和master分支冲突时,需要进入该冲突的文件进行修改后重新提交
解决冲突后可以使用带参数的git log查看合并过程:
| |
2.6 储藏工作现场
储存工作现场:
| |
查看储存的工作现场:
| |
恢复工作现场:
| |
2.7 远程仓库操作
| |
远程操作步骤: 1、首先,可以试图用git push origin [branchname]推送自己的修改;
2、如果推送失败,则因为远程分支比你的本地更新,需要先用git pull试图合并;
3、如果合并有冲突,则解决冲突,并在本地提交;
4、没有冲突或者解决掉冲突后,再用git push origin [branchname]推送就能成功!
如果git pull提示“no tracking information”,则说明本地分支和远程分支的链接关系没有创建,用命令git branch --set-upstream branch-name origin/branch-name。
3、git常见命令
3.1 merge
git merge taget_branch
3.2 reset
一、首先解析以下这三个相关的状态和概念
1、HEAD:可以描述为当前分支最后一个提交。即本地的信息中的当前版本。
2、Index:在工作副本修改之后执行过git add操作的版本文件,可以commit了的。
3、Working Copy:工作副本是你正在修改,但是没有执行任何git操作的文件。
总的来说:
代码修改,还没做任何操作的时候就是 Working Copy,
git add * 操作之后就是Index,
git commit 之后就是HEAD。如果代码修改了之后进行git add 操作,然后git commit,那么所有三者(HEAD,INDEX(STAGING),WORKING COPY)都是相同的状态,内容相同。
二、reset
1、soft(更改HEAD)(恢复git commit的操作)
软重置。本来origin的HEAD和本地的HEAD一样,如果你指定--soft参数,Git只是单纯的把本地HEAD更改到你指定的版本那么,整个过程中,就HEAD的定义发生了变化,其他像Working Copy 和Index都没有变化。该参数用于git commit后,又要恢复还没commit的场景,重新审查代码,然后再推上去。
2、mixed(default)(恢复git add的操作,包含恢复git commit的操作)
--mixed是reset的默认参数,也就是当你不指定任何参数时的参数。它将重置HEAD到另外一个commit,并且重置index以便和HEAD相匹配。
3、hard(更改三者)
--hard参数将会将会重置(HEAD,INDEX(STAGING),WORKING COPY),强制一致。该参数用于在把工作副本改成一塌糊涂的时候,包括工作副本,一股脑恢复。有些就单纯修改文件,其中有些git add了,有些git commit了,通通不管,可以一个命令恢复。
总结:
1、soft: 重置git commit
2、mixed: 重置git commit 和 git add
3、hard: 重置git commit 和 git add 和工作副本的修改。
3.3 rebase
命令介绍
git pull --rebase
git pull的默认行为是git fetch + git merge
git pull --rebase则是git fetch + git rebase.
git fetch
从远程获取最新版本到本地,不会自动合并分支
git rebase
git rebase,顾名思义,就是重新定义(re)起点(base)的作用,即重新定义分支的版本库状态。本地更新分支节点过程如下图所示。

git pull --rebase
git pull --rebase执行过程中会将本地当前分支里的每个提交(commit)取消掉,然后把将本地当前分支更新为最新的"origin"分支,该过程本地分支节点更新图如下所示:

解决冲突方法
执行完git pull --rebase之后如果有合并冲突,使用以下三种方式处理这些冲突:
git rebase --abort 会放弃合并,回到rebase操作之前的状态,之前的提交的不会丢弃;
git rebase --skip 则会将引起冲突的commits丢弃掉(慎用!!);
git rebase --continue 合并冲突,结合"git add 文件"命令一起用与修复冲突,提示开发者,一步一步地有没有解决冲突。(fix conflicts and then run "git rebase --continue")
对上述冲突的处理
1、使用 $git rebase --abort
执行之后,本地内容会回到提交之间的状态,也就是回到以前提交但没有pull是的状态,简单来说就是撤销rebase。
2、使用 $git rebase --skip
git rebase --skip 引起冲突的commits会被丢弃,对于本文应用的例子来说开发者A对c.sh文件的commit无效,开发者A自己修改的部分全部无效,因此,在使用skip时请慎重。
执行:$ vim c.sh
查看本地c.sh文件提交内容,展示如下图所示,执行语句之后开发者A的修改无效。
3、使用 $git rebase --continue
执行完$git pull --rebase 之后,本地如果产生冲突,手动解决冲突之后,用"git add ."(注意这里可以不用commit,因为已经commit了,现在只需要调整commit的顺序而已,个人理解)命令去更新这些内容的索引(index),然后只要执行:
$ git rebase --continue 就可以线性的连接本地分支与远程分支,无误之后就回退出,回到主分支上。
注意:一般情况下,修改后检查没问题,使用rebase continue来合并冲突。
4、提交规范
Commit message 的格式
每次提交,Commit message 都包括三个部分:Header,Body 和 Footer。
| |
其中Header是必须的,Body和Footer可以省略
Header
Header部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。
1)type
type用于说明 commit 的类别,只允许使用下面7个标识。
- feat:新功能(feature)
- fix:修补bug
- docs:文档(documentation)
- style: 格式(不影响代码运行的变动)
- refactor:重构(即不是新增功能,也不是修改bug的代码变动)
- test:增加测试
- chore:构建过程或辅助工具的变动 如果type为feat和fix,则该 commit 将肯定出现在 Change log 之中。其他情况(docs、chore、style、refactor、test)由你决定,要不要放入 Change log,建议是不要。
(2)scope
scope用于说明 commit 影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。
(3)subject
subject是 commit 目的的简短描述,不超过50个字符。
- 以动词开头,使用第一人称现在时,比如change,而不是changed或changes
- 第一个字母小写
- 结尾不加句号(.)
注意:
- 一般提交代码标题和commit都要用header中的type
- feat后用英式:,且要有空格,空格后要用小写,否则报错 Tips:
go的import要求顺序导入,可安装go fmt或者goimports,然后保存自动格式化
cmd+,选择Tools/File Watches
Reference
关于git的reset指令说明-soft、mixed、hard
1.2 - gitbook使用指南
gitbook
安装
| |
创建
| |
此时出现问题,按照如下解决
问题
https://www.cnblogs.com/cyxroot/p/13754475.html
- TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string
https://xiaosongshine.blog.csdn.net/article/details/116235787
mac node版本降级
| |
运行
| |
安装插件
插件官网
通用安装方法:
| |
提前准备
book.js文件准备,在gitbook项目根目录下
| |
搜索加强
| |
在book.js中添加
| |
代码框插件
| |
在book.js中添加
| |
自定义主题插件
| |
在book.js中添加
| |
菜单折叠插件
| |
在book.js中添加
| |
返回顶部插件
| |
在book.js中添加
| |
页内目录插件
| |
按照文件自动生成SUMMARY
| |
github配置pages
1、创建chnherb.github.io仓库
2、提交代码到 gh-pages 分支
3、在 settings->pages 中设置 Source 为 gh-pages
Reference
1.3 - github搜索技巧
github网页结构
网页上有项目名、更新日期、简介、贡献者、编程语言等信息
搜索技巧
in关键词搜索
关键字 in 可以搜索出 GitHub 上的资源名称 name、说明 description 和 readme 文件中的内容。description 就是 About 那一块的信息。
比如说
| |
其中,逗号分割表示或的意思,意思就是三者中只要有一个有 java 就行。
stars/fork数量搜索
搜索 GitHub 时用 star 数量和 fork 数量判断这个项目是否优秀的标准之一,我们可以使用 大小,小于,范围等方式过滤:
| |
表示星数大于 1000 且 forks 数大于 500,名字中含有 java 的项目。 如果要指定范围,可以这样:
| |
表示星数在 5000 到 10000 之间,名字中有 java 的项目。
按创建/更新时间搜索
按创建、更新时间搜索可以把版本老旧的资源筛选出去,比如说:
按创建时间:created:>=YYYY-MM-DD
按更新时间:pushed:>=YYYY-MM-DD
比如说搜索 2021 年之后创建的 java 项目:
| |
按文件/路径内容搜索
在 GitHub 还可以按文件内容和文件路径搜索,不过有一定的限制,首先必须登录,此外项目的文件不能太多,文件不能太大,在需要搜索 fork 资源 时,只能搜索到 star 数量比父级资源多的 fork 资源,并需要加上 fork:true 查询,搜索结果最多可显示同一文件的两个分段,但文件内可能有更多结果,不能使用通配符。
语法格式:
| |
比如:
| |
按文件名、大小、扩展名搜索
语法格式如下:
| |
举个例子:
| |
按编程语言来搜索
语法格式:
| |
比如:python language:javascript 表示搜索 javascrip 语言中关于 python 的项目。
常用搜索
| |
1.4 - 快捷键
IDE快捷键
| 功能 | Intellij Mac | VSCode Mac | Eclipse Windows | Intellij Windows | Eclipse Mac |
|---|---|---|---|---|---|
| 格式化代码 | cmd+opt+L | Shift+Opt+F | ctrl+shift+F | ||
| 注释代码 | cmd+/ | cmd+/ | ctrl+/ | ||
| 删除行 | cmd+X | ctrl+D | |||
| 复制行 | cmd+D | ||||
| 接口或定义 | cmd+B | ||||
| 具体实现 | cmd+opt+B | ||||
| super方法 | cmd + U | ||||
| 上/下一个操作位置 | cmd+opt+←/→ | ctrl - shift ctrl - | |||
| 类的层次结构 | ctrl+H | ||||
| 方法层次结构 | ctrl+shift+H | ||||
| 调用层次结构 | ctrl+opt+H opt + F7 | ||||
| 全局查找 | cmd+shift+F | ||||
| 搜索方法(包括父类) | cmd+F12 | Ctrl+O | |||
| 查看方法被引用 | alt+F7 | ctrl+alt+H | |||
| 查看变量被引用 | ctrl+G | ||||
| 补全赋值 | cmd+opt+v | ||||
| 覆盖方法(重写父类方法) | ctrl + O | ||||
| 实现方法(实现接口中的方法) | ctrl + I | ||||
| 优化import | ctrl + opt + O | ||||
| 最近打开的文件 | cmd + E | ||||
| 系统设置 | cmd + , | ||||
| 项目结构 | cmd + ; | ||||
| 重命名文件 | shift + F6 |
VSCode
VSCode 格式化代码:shift+alt+F,可使用插件:IntelliJ IDEA Key Bindings
cmd+p 跳转 (如文件路径+行数)
cmd+shift+p 执行命令
Mac
复制文件: Cmd + C 、Cmd + V
剪切文件: Cmd + C、Cmd + opt + V
隐藏当前程序: cmd + H
全屏: Ctrl+Cmd + F
chrome恢复关闭tab: shift+command+T
finder:显示所有文件夹 shift+cmd+. 前往任何文件夹 shift+cmd+G
alfred 剪切板
Command Alt(option) + B
截图:^ + command + A
多个QQ: command + N
eclipse mac
command + d 删除行
command + / 注释 反注释
command + shift + F 格式化代码
command + option + down 复制到下一行
F3 查看定义
command + [/] 定位前/后一个访问的位置
Finder&shell
open . #从终端打开当前finder路径
Photoshop shortcuts:
cmd + D 取消选中块
scala
/usr/local/Cellar/scala/2.12.8
:q
sublime
json格式化
pretty json 格式化: cmd+ctrl+j
sublime列操作:
cmd+A
cmd+shift+L 列编辑
->
(编辑)
替换成回车 cmd+shift+enter
1.5 - hugo搭建个人网站
hugo下载&安装
hugo:https://github.com/gohugoio/hugo/releases
下载对应版本,然后解压并配置全局可用,如:
| |
docsy
搭建步骤
| |
gitbook文件转换
| |
增加文件头
| |
适配图片相对路径
| |
适配文件路径大小写
| |
node版本升级
| |
hugo-shortcode
折叠代码
在 ./layouts/shortcodes/ 目录下新建 code.html 文件,内容如下:
| |
markdown 文件中使用代码折叠功能:
这里为了防止转义,使用了“﹛﹜” 来代替 “{}”,注意替换不要直接复制
| |
代码滚动
| |
参考博客: https://its201.com/article/weixin_41929524/124956480 https://orianna-zzo.github.io/sci-tech/2018-08/blog%E5%85%BB%E6%88%90%E8%AE%B016-%E8%87%AA%E5%BB%BAhugo%E7%9A%84toc%E6%A8%A1%E6%9D%BF/
hugo-notice
notice 库:https://github.com/martignoni/hugo-notice
使用方法:
将 notice.html 复制到 ./layouts/shortcodes/ 目录下即可
更多详情参考:在 Hugo 博客上实践 Shortcodes 短代码
常见问题
fatal error: pipe failed
临时解决办法:
| |
永久解决办法:
| |
Reference
https://www.docsy.dev/docs/get-started/docsy-as-module/installation-prerequisites/
https://skyao.io/learning-hugo/docs/theme/docsy/setup.html
https://gohugo.io/content-management/syntax-highlighting/
https://zhuanlan.zhihu.com/p/98680055
1.6 - 正则表达式
基础正则表达式速查表
字符
| 表达式 | 描述 |
|---|---|
| [abc] | 字符集。匹配集合中所含的任一字符。 |
| [^abc] | 否定字符集。匹配任何不在集合中的字符。 |
| [a-z] | 字符范围。匹配指定范围内的任意字符。 |
| . | 匹配除换行符以外的任何单个字符。 |
| |转义字符。 | |
| \w | 匹配任何字母数字,包括下划线(等价于[A-Za-z0-9_])。 |
| \W | 匹配任何非字母数字(等价于[^A-Za-z0-9_])。 |
| \d | 数字。匹配任何数字。 |
| \D | 非数字。匹配任何非数字字符。 |
| \s | 空白。匹配任何空白字符,包括空格、制表符等。 |
| \S | 非空白。匹配任何非空白字符。 |
分组和引用
| 表达式 | 描述 |
|---|---|
| (expression) | 分组。匹配括号里的整个表达式。 |
| (?:expression) | 非捕获分组。匹配括号里的整个字符串但不获取匹配结果,拿不到分组引用。 |
| \num | 对前面所匹配分组的引用。比如(\d)\1可以匹配两个相同的数字,(Code)(Sheep)\1\2则可以匹配CodeSheepCodeSheep。 |
锚点/边界
| 表达式 | 描述 |
|---|---|
| ^ | 匹配字符串或行开头。 |
| $ | 匹配字符串或行结尾。 |
| \b | 匹配单词边界。比如Sheep\b可以匹配CodeSheep末尾的Sheep,不能匹配CodeSheepCode中的Sheep。 |
| \B | 匹配非单词边界。比如Code\B可以匹配HelloCodeSheep中的Code,不能匹配HelloCode中的Code。 |
数量表示
| 表达式 | 描述 |
|---|---|
| ? | 匹配前面的表达式0个或1个。即表示可选项。 |
| + | 匹配前面的表达式至少1个。 |
| * | 匹配前面的表达式0个或多个。 |
| | | 或运算符。并集,可以匹配符号前后的表达式。 |
| {m} | 匹配前面的表达式m个。 |
| {m,} | 匹配前面的表达式最少m个。 |
| {m,n} | 匹配前面的表达式最少m个,最多n个。 |
预查断言
| 表达式 | 描述 |
|---|---|
| (?=) | 正向预查。比如Code(?=Sheep)能匹配CodeSheep中的Code,但不能匹配CodePig中的Code。 |
| (?!) | 正向否定预查。比如Code(?!Sheep)不能匹配CodeSheep中的Code,但能匹配CodePig中的Code。 |
| (?<=) | 反向预查。比如(?<=Code)Sheep能匹配CodeSheep中的Sheep,但不能匹配ReadSheep中的Sheep。 |
| (?<!) | 反向否定预查。比如(?<!Code)Sheep不能匹配CodeSheep中的Sheep,但能匹配ReadSheep中的Sheep。 |
特殊标志
| 表达式 | 描述 |
|---|---|
| /.../i | 忽略大小写。 |
| /.../g | 全局匹配。 |
| /.../m | 多行修饰符。用于多行匹配。 |
常用正则表达式
数字校验
| 描述 | 正则表达式 | 备注 |
|---|---|---|
| 数字 | ^[0-9]*$ | |
| n位数字 | ^\d{n}$ | |
| 至少n位数字 | ^\d{n,}$ | |
| m~n位数字 | ^\d{m,n}$ | |
| 整数 | ^(-?[1-9]\d*)$ | 非0开头,包括正整数和负整数 |
| 正整数 | ^[1-9]\d*$ | |
| 负整数 | ^-[1-9]\d*$ | |
| 非负整数 | ^(([1-9]\d*)|0)$ | |
| 非正整数 | ^((-[1-9]\d*)|0)$ | |
| 浮点数 | ^-?(?:[1-9]\d*.\d*|0.\d*[1-9]\d*|0.0+|0)$ | 包括正浮点数和负浮点数 |
| 正浮点数 | ^(?:[1-9]\d*.\d*|0.\d*[1-9]\d*)$ | |
| 负浮点数 | ^-(?:[1-9]\d*.\d*|0.\d*[1-9]\d*)$ | |
| 非正浮点数 | ^(?:-(?:[1-9]\d*.\d+|0.\d*[1-9]\d*)|0.0+|0)$ | 包含0 |
| 非负浮点数 | ^(?:[1-9]\d*.\d+|0.\d+|0.0+|0)$ | 包含0 |
| 仅一位小数 | ^-?(?:0|[1-9][0-9]*).[0-9]{1}$ | |
| 最少一位小数 | ^-?(?:0|[1-9][0-9]*).[0-9]{1,}$ | |
| 最多两位小数 | ^-?(?:0|[1-9][0-9]*).[0-9]{1,2}$ | |
| 连续重复的数字 | ^(\d)\1+$ | 例如:111,222 |
字符校验
| 描述 | 正则表达式 | 备注 |
|---|---|---|
| 中文 | ^[\u4E00-\u9FA5]+$ | |
| 全角字符 | ^[\uFF00-\uFFFF]+$ | |
| 半角字符 | ^[\u0000-\u00FF]+$ | |
| 英文字符串(大写) | ^[A-Z]+$ | |
| 英文字符串(小写) | ^[a-z]+$ | |
| 英文字符串(不区分大小写) | ^[A-Za-z]+$ | |
| 中文和数字 | ^(?:[\u4E00-\u9FA5]{0,}|\d)+$ | |
| 英文和数字 | ^[A-Za-z0-9]+$ | |
| 数字、英文字母或者下划线组成的字符串 | ^\w+$ | |
| 中文、英文、数字包括下划线 | ^[\u4E00-\u9FA5\w]+$ | |
| 不含字母的字符串 | ^[^A-Za-z]*$ | |
| 连续重复的字符串 | ^(.)\1+$ | 例如:aaa,bbb |
| 长度为n的字符串 | ^.{n}$ | |
| ASCII | ^[ -~]$ |
日期和时间校验
| 描述 | 正则表达式 | 备注 |
|---|---|---|
| 日期 | ^\d{1,4}-(?:1[0-2]|0?[1-9])-(?:0?[1-9]|[1-2]\d|30|31)$ | 弱校验,例如:2022-06-12 |
| 日期 | ^(?:(?!0000)[0-9]{4}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1[0-9]|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:0[48]|[2468][048]|[13579][26])00)-02-29)$ | 严格校验,考虑平闰年 |
| 时间 | ^(?:1[0-2]|0?[1-9]):[0-5]\d:[0-5]\d$ | 12小时制,例如:11:21:31 |
| 时间 | ^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$ | 24小时制,例如:23:21:31 |
| 日期+时间 | ^(\d{1,4}-(?:1[0-2]|0?[1-9])-(?:0?[1-9]|[1-2]\d|30|31)) ((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)$ | 例如:2000-11-11 23:20:21 |
日常生活相关
| 描述 | 正则表达式 | 备注 |
|---|---|---|
| 中文名 | ^[\u4E00-\u9FA5·]{2,16}$ | |
| 英文名 | ^[a-zA-Z][a-zA-Z\s]{0,20}[a-zA-Z]$ | |
| 车牌号 | ^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]$ | 不含新能源 |
| 车牌号 | ^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领]A-HJ-NP-Z$ | 包含新能源 |
| 火车车次 | ^[GCDZTSPKXLY1-9]\d{1,4}$ | 例如:G1234 |
| 手机号 | ^(?:(?:+|00)86)?1[3-9]\d{9}$ | 弱匹配 |
| 手机号 | ^(?:(?:+|00)86)?1(?:(?:3[\d])|(?:4[5-79])|(?:5[0-35-9])|(?:6[5-7])|(?:7[0-8])|(?:8[\d])|(?:9[189]))\d{8}$ | 严格匹配 |
| 固话号码 | ^(?:(?:\d{3}-)?\d{8}|^(?:\d{4}-)?\d{7,8})(?:-\d+)?$ | |
| 手机IMEI码 | ^\d{15,17}$ | 一般是15位 |
| 邮编 | ^(?:0[1-7]|1[0-356]|2[0-7]|3[0-6]|4[0-7]|5[1-7]|6[1-7]|7[0-5]|8[013-6])\d{4}$ | 例如:211100 |
| 统一社会信用代码 | ^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$ | |
| 身份证号码(1代) | ^[1-9]\d{7}(?:0\d|10|11|12)(?:0[1-9]|[1-2][\d]|30|31)\d{3}$ | 15位数字 |
| 身份证号码(2代) | ^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[0-9Xx]$ | 18位数字 |
| QQ号 | ^[1-9][0-9]{4,}$ | 一般是5到10位 |
| 微信号 | ^[a-zA-Z][-_a-zA-Z0-9]{5,19}$ | 一般6~20位,字母开头,可包含字母、数字、-、_,不含特殊字符 |
| 股票代码 | ^(s[hz]|S[HZ])(000[\d]{3}|002[\d]{3}|300[\d]{3}|600[\d]{3}|60[\d]{4})$ | A股,例如:600519 |
| 银行卡卡号 | ^[1-9]{1}(?:\d{15}|\d{18})$ | 一般为19位 |
互联网相关
| 描述 | 正则表达式 | 备注 |
|---|---|---|
| 域名 | ^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(?:.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$ | 例如:r2coding.com |
| 网址 | ^(?:https?://)?[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(?:.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$ | 例如:https://www.r2coding.com/ |
| 带端口号的网址(或IP) | ^(?:https?://)?[\w-]+(?:.[\w-]+)+:\d{1,5}/?$ | 例如:http://127.0.0.1:8888/ |
| URL | ^https?://(?:www.)?[-a-zA-Z0-9@:%.+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()!@:%+.~#?&//=]*)$ | 例如:https://www.r2coding.com/#/README?id=1 |
| 邮箱email | ^[A-Za-z0-9\u4e00-\u9fa5]+@[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(?:.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+$ | 支持中文,例如:codesheep@cs.com |
| 用户名 | ^[a-zA-Z0-9_-]{4,20}$ | 4到20位 |
| 弱密码 | ^[\w]{6,16}$ | 6~16位,包含大小写字母和数字的组合 |
| 强密码 | ^.(?=.{6,})(?=.\d)(?=.[A-Z])(?=.[a-z])(?=.[!@.#$%^&? ]).*$ | 至少6位,包括至少1个大写字母,1个小写字母,1个数字,1个特殊字符 |
| 端口号 | ^(?:[0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$ | 例如:65535 |
| IPv4地址 | ^(?:(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]).){3}(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])$ | 例如:192.168.31.1 |
| IPv4地址+端口 | ^(?:(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]).){3}(?:\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?::(?:[0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ | 例如:192.168.31.1:8080 |
| IPv6地址 | ^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$ | 例如:CDCD:910A:2222:5498:8475:1111:3900:2020 |
| IPv6地址+端口 | ^[(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))](?::(?:[0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ | 例如:[CDCD:910A:2222:5498:8475:1111:3900:2020]:9800 |
| 子网掩码 | ^(?:254|252|248|240|224|192|128).0.0.0|255.(?:254|252|248|240|224|192|128|0).0.0|255.255.(?:254|252|248|240|224|192|128|0).0|255.255.255.(?:255|254|252|248|240|224|192|128|0)$ | 例如:255.255.255.0 |
| MAC地址 | ^(?:(?:[a-f0-9A-F]{2}:){5}|(?:[a-f0-9A-F]{2}-){5})[a-f0-9A-F]{2}$ | |
| Version版本号 | ^\d+(?:.\d+){2}$ | 例如:12.1.1 |
| 图片后缀 | .(gif|png|jpg|jpeg|webp|svg|psd|bmp|tif)+ | 可按需增删扩展名集合 |
| 视频后缀 | .(swf|avi|flv|mpg|rm|mov|wav|asf|3gp|mkv|rmvb|mp4)+ | 可按需增删扩展名集合 |
| 图片链接 | (?:https?://)?[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(?:.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+.+.(gif|png|jpg|jpeg|webp|svg|psd|bmp|tif) | 可按需增删扩展名集合 |
| 视频链接 | (?:https?://)?[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(?:.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+.+.(swf|avi|flv|mpg|rm|mov|wav|asf|3gp|mkv|rmvb|mp4) | 可按需增删扩展名集合 |
| 迅雷链接 | thunderx?://[a-zA-Z\d]+= | |
| ed2k链接 | ed2k://\|file\|.+\|/ | |
| 磁力链接 | magnet:?xt=urn:btih:[0-9a-fA-F]{40,}.* |
其他
| 描述 | 正则表达式 | 备注 |
|---|---|---|
| Windows文件路径 | ^[a-zA-Z]:(?:\[\w\u4E00-\u9FA5\s]+)+[.\w\u4E00-\u9FA5\s]+$ | 例如:C:\Users\Administrator\Desktop\a.txt |
| Windows文件夹路径 | ^[a-zA-Z]:(?:\[\w\u4E00-\u9FA5\s]+)+$ | 例如:C:\Users\Administrator\Desktop |
| Linux文件路径 | ^/(?:[^/]+/)*[^/]+$ | 例如:/root/library/a.txt |
| Linux文件夹路径 | ^/(?:[^/]+/)*$ | 例如:/root/library/ |
| MD5格式 | ^(?:[a-f\d]{32}|[A-F\d]{32})$ | 32位MD5,例如:7552E7071B118CBFFEC8C930455B4297 |
| BASE64格式 | ^\sdata:(?:[a-z]+/[a-z0-9-+.]+(?:;[a-z-]+=[a-z0-9-]+)?)?(?:;base64)?,([a-z0-9!$&',()+;=-._~:@/?%\s]?)\s$ | 例如:data:image/jpeg;base64,xxxx== |
| UUID | ^[a-f\d]{4}(?:[a-f\d]{4}-){4}[a-f\d]{12}$ | 例如:94f9d45a-71b0-4b3c-b69d-20c4bc9c8fdd |
| 16进制 | ^[A-Fa-f0-9]+$ | 例如:FFFFFF |
| 16进制颜色 | ^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ | 例如:#FFFFFF |
| SQL语句 | ^(?:select|drop|delete|create|update|insert).*$ | |
| Java包名 | ^(?:[a-zA-Z_]\w*)+(?:[.][a-zA-Z_]\w*)+$ | 例如:com.r2coding.controller |
| 文件扩展名 | .(?:doc|pdf|txt) | 可按需增删扩展名集合 |
| HTML标签 | <(\w+)[^>]>(.?</\1>)? | 例如: |
| HTML注释 | 例如: |
2 - OS
Introduction
操作系统,如Linux和Shell基本知识
2.1 - 00.OS相关概念
Namespace
当一台物理主机(宿主机)运行容器的时候,为了避免容器所需系统资源之间相互干扰。所以Docker利用操作系统的隔离技术-NameSpace,来实现在同一个操作系统中,不同容器之间的资源独立隔离运行。
隔离系统资源列表
Linux NameSpace是Linux系统提供的一种资源隔离机制,可实现系统资源的列表如下:
| Mount | 用于隔离文件系统的挂载点 |
|---|---|
| UTS | 用于隔离HostName和DomainName |
| IPC | 用于隔离进程间通信 |
| PID | 用于隔离进程ID |
| NetWork | 用于隔离网络 |
| User | 用于隔离用户和用户组 UID/GID |
查看系统资源隔离
查找进程
| |
查看NameSpace(NS)
| |
Linux CGroup
Linux Control Group, 是Linux内核的一个功能,用来限制、控制与分离一个进程组群的资源(如CPU、内存、磁盘输入输出等)。这个项目最早是由Google的工程师在2006年发起(主要是Paul Menage和Rohit Seth),最早的名称为进程容器(process containers)。在2007年时,因为在Linux内核中,容器(container)这个名词太过广泛,为避免混乱,被重命名为cgroup,并且被合并到2.6.24版的内核中去。然后,其它开始了他的发展。
Linux CGroupCgroup 可为系统中所运行任务(进程)的用户定义组群分配资源 — 比如 CPU 时间、系统内存、网络带宽或者这些资源的组合。可以监控配置的 cgroup,拒绝 cgroup 访问某些资源,甚至在运行的系统中动态配置cgroup。
主要功能:
限制资源使用,比如内存使用上限以及文件系统的缓存限制。
优先级控制,CPU利用和磁盘IO吞吐。
一些审计或一些统计,主要目的是为了计费。
挂起进程,恢复执行进程。
cgroups子系统
1、cpu 子系统
主要限制进程的 cpu 使用率。
2、cpuacct 子系统
可以统计 cgroups 中的进程的 cpu 使用报告。
3、cpuset 子系统
可以为 cgroups 中的进程分配单独的 cpu 节点或者内存节点。
4、memory 子系统
可以限制进程的 memory 使用量。
5、blkio 子系统
可以限制进程的块设备 io。
6、devices 子系统
可以控制进程能够访问某些设备。
7、net_cls 子系统
可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块(traffic control)对数据包进行控制。
8、net_prio
用来设计网络流量的优先级
9、freezer 子系统
可以挂起或者恢复 cgroups 中的进程。
10、ns 子系统
可以使不同 cgroups 下面的进程使用不同的 namespace
11、hugetlb
主要针对于HugeTLB系统进行限制,这是一个大页文件系统。
cgroups层级结构(Hierarchy)
内核使用 cgroup 结构体来表示一个 control group 对某一个或者某几个 cgroups 子系统的资源限制。cgroup 结构体可以组织成一颗树的形式,每一棵cgroup 结构体组成的树称之为一个 cgroups 层级结构。
cgroups层级结构可以 attach 一个或者几个 cgroups 子系统,当前层级结构可以对其 attach 的 cgroups 子系统进行资源的限制。每一个 cgroups 子系统只能被 attach 到一个 cpu 层级结构中。

创建了 cgroups 层级结构中的节点(cgroup 结构体)之后,可以把进程加入到某一个节点的控制任务列表中,一个节点的控制列表中的所有进程都会受到当前节点的资源限制。同时某一个进程也可以被加入到不同的 cgroups 层级结构的节点中,因为不同的 cgroups 层级结构可以负责不同的系统资源。所以说进程和 cgroup 结构体是一个多对多的关系。
查看系统资源限制
| |
CPU限制实战
查看cgroup挂载点
| |
创建隔离组
| |
测试cpu
死循环程序测试cpu
| |
启动程序后cpu使用100% 默认-1不限制,现在改成30000,可以理解使用率限制在30%
| |
找到进程号增加到cpu tasks里面,再看top cpu使用率很快就下来
| |
其他资源限制同cpu限制
内存限制实战
| |
Reference
https://github.com/opencontainers/runtime-spec/blob/main/config-linux.md#control-groups
2.2 - Linux负载均衡LVS之IPVS
名词概念
LVS
LVS(Linux Virtual Server)即Linux虚拟服务器,是一个虚拟的服务器集群系统,由章文嵩博士在1998年5月成立,在linux2.6+后将lvs自动加入了kernel模块。
LVS的用户空间的命令行管理工具为ipvsadm,ipvs是工作在内核中netfilter的INPUT的钩子函数上,对进入的报文在没有进入用户空间前,对这些报文进行操作。
IPVS
IPVS是LVS(Linux Virtual Server)项目重要组成部分,目前包含于官方Linux Kernel,IPVS依赖于netfilter框架,位于内核源码的net/netfilter/ipvs目录下。
IPVS是LVS项目的一部分,是一款运行在Linux kernel当中的4层负载均衡器,性能异常优秀。
RS
realserver,常简称为RS
NAT
网络地址转换(Network Address Translate)
SNAT
原地址转换
DNAT
目标地址转换
几种IP

VIP
virtual IP,LVS服务器上接收外网数据包的网卡IP地址。
DIP
director IP,LVS服务器上转发数据包到realserver的网卡IP地址。
RIP
realserver(常简称为RS)上接收Director转发数据包的IP,即提供服务的服务器IP。
CIP:客户端的IP。
IPVS的三种转发模式
DR模式(Direct Routing)
DR模式下,客户端的请求包到达负载均衡器的虚拟服务IP端口后,负载均衡器不会改写请求包的IP和端口,但是会改写请求包的MAC地址为后端RS的MAC地址,然后将数据包转发;真实服务器处理请求后,响应包直接回给客户端,不再经过负载均衡器。所以DR模式的转发效率是最高的,特别适合下行流量较大的业务场景,比如请求视频等大文件。
NAT模式(Network Address Translation)
NAT模式下,请求包和响应包都需要经过LB处理。当客户端的请求到达虚拟服务后,LB会对请求包做目的地址转换(DNAT),将请求包的目的IP改写为RS的IP。当收到RS的响应后,LB会对响应包做源地址转换(SNAT),将响应包的源IP改写为LB的IP。
TUN模式
采用NAT技术时,由于请求和响应报文都必须经过调度器地址重写,当客户请求越来越多时,调度器的处理能力将成为瓶颈。为了解决这个问题,调度器把请求报文通过IP隧道转发至真实服务器,而真实服务器将响应直接返回给客户,所以调度器只处理请求报文。由于一般网络服务响应报文比请求报文大许多,采用VS/TUN技术后,调度器得到极大的解放,集群系统的最大吞吐量可以提高10倍。
FULLNAT模式
FULLNAT模式下,LB会对请求包和响应包都做SNAT+DNAT。
转发模式对比
三种转发模式性能从高到低:DR > NAT >FULLNAT。
虽然FULLNAT模式的性能比不上DR和NAT,但是FULLNAT模式没有组网要求,允许LB和RS部署在不同的子网中,这给运维带来了便利。并且 FULLNAT模式具有更好的可拓展性,可以通过增加更多的LB节点,提升系统整体的负载均衡能力。
IPVS实现Service
背景知识
一定要让Kernel认为VIP是本地地址,这样4层的LVS才能开始工作。
网络结构

步骤
1、绑定VIP到本地(欺骗内核)
| |
2、为该虚IP创建一个IPVS的virtual server
| |
3、为该IPVS service创建相应的real server
| |
Reference
2.3 - shell
Introduction
shell脚本相关
2.3.1 - Shebang
简介
在计算机领域,Shebang(也称为Hashbang)是一个由井号和叹号构成的字符序列 #! ,其出现在文本文件中的第一行的前两个字符。
在文件中存在 Shebang 的情况下,类 Unix 操作系统的程序加载器会分析 Shebang 后的内容,将这些内容作为解释器指令,并调用该指令,并将载有 Shebang 的文件路径作为该解释器的参数。
例如,以指令 #!/bin/sh 开头的文件在执行时会实际调用 /bin/sh 程序。
由于 # 符号在许多脚本语言中都是注释标识符,Shebang 的内容会被这些脚本解释器自动忽略。 在 # 字符不是注释标识符的语言中,例如 Scheme,解释器也可能忽略以 #! 开头的首行内容,以提供与 Shebang 的兼容性。
env bash
使用方式:
| |
env 的作用
env 命令用于显式系统中已存在的环境变量,以及在定义的环境中执行命令。
| |
与 #!/bin/bash 声明了 bash 所在位置,系统知道去哪里找 bash 相比, #!/usr/bin/env bash 只声明了 env 所在位置,然后去 $PATH 中找 bash 的位置。
比如执行 env python 时,它其实会去 env | grep PATH 中的几个路径中依次寻找 python 的可执行文件。
env bash和bash对比
env bash优缺点
优点:
#!/usr/bin/env bash不必在系统的特定位置查找命令解释器,为多系统间的移植提供了极大的灵活性和便利性(某些系统的一些命令解释器并不在 /bin 或一些约定的目录下,而是一些比较奇怪的目录)不了解主机环境时,
#!/usr/bin/env bash可以使开发工作快速开展 缺点:对安全性比较看重时,该写法会出现安全隐患
该写法会从 $PATH 中查找命令解释器所在的位置并匹配第一个找到的位置,这意味着可以伪造一个假的命令解释器,并将伪造后的命令解释器所在目录写入 PATH 环境变量中并位于靠前位置,这样就形成了安全隐患。
- 因为 Shebang 解析的设计导致无法传递多个多个参数
如
#!/usr/bin/perl -w和#!/bin/csh -f,而如果使用#!/usr/bin/env perl -w这种写法的话,perl -w 会被当成一个参数,但根本找不到 perl -w 这个命令解释器,就会出错。
- 某些系统 env 命令的位置不在 /usr/bin 下
bash优缺点
优点:
准确指出所需命令解释器的位置
安全性相对较高
可以传递多个参数 缺点:
移植性相对较差,很多系统的命令解释器位置不一致
一些命令解释器的位置记不住
2.3.2 - curl&wget
curl
语法
| |
参数说明
-d/--data
参数用于发送 POST 请求的数据体。
| |
1、--data value如果是@a_file_name,表示数据来自一个文件,文件中的回车符和换行符将被转换
2、--data-ascii <key=value>
完全等价于-d
- --data-binary key=value
HTTP POST请求中的数据为纯二进制数据
value如果是@file_name,则保留文件中的回车符和换行符,不做任何转换
- --data-raw key=value
@也作为普通字符串,不会作为文件名给出文件名的标志。即value如果是@file_name,只表示值为“@file_name”的字符串。
其他等价于-d
- --data-urlencode key=value
基本同-d,区别在于会将发送的数据进行 URL 编码,如空格等
-f/--fail
禁止服务器在打开页面失败或脚本调用失败时向客户端发送错误说明,取而代之,curl 会返回错误码 22
| |
-F/--form
模拟表单
| |
-H/--header
增加头信息
-i
打印出服务器回应的 HTTP 标头。先输出服务器回应的标头,然后空一行,再输出网页的源码。
| |
-I/--head
向服务器发出 HEAD 请求,然会将服务器返回的 HTTP 标头打印出来。
| |
-L/--location
让 HTTP 请求跟随服务器的重定向。curl 默认不跟随重定向。
| |
-o/--output
将服务器的回应保存成文件,等同于wget命令
| |
-O
服务器回应保存成文件,并将 URL 的最后部分当作文件名
| |
-p/--proxytunnel
-s
不输出错误和进度信息
| |
-S
参数指定只输出错误信息,通常与-o一起使用。
| |
-v/--verbose
显示更多更详细的信息,调试时使用
-x
指定 HTTP 请求的代理,如果没有指定代理协议,默认为 HTTP。
| |
-X/--request
指定 HTTP 请求的方法
| |
实战
连接是否可用
| |
增加header
Expand/Collapse Code Block
| |
上传文件
| |
获取当前机器ip
| |
wget
语法说明
参数说明
-b
后台下载
| |
-c/--continue
断点续传
对于我们下载大文件时突然由于网络等原因中断非常有帮助,我们可以继续接着下载而不是重新下载一个文件
-i
下载多个文件
| |
-reject
过滤指定格式下载
| |
-limit-rate
设置下载速率
-o
把下载信息存入日志文件而不是显示在终端
| |
-O/--output
下载并以不同文件名保存
下载到对应目录,并且修改文件名称
| |
-q/--quiet
安静模式(无信息输出)
-Q/--quota
设置下载的容量限制
-v/--verbose
输出详细信息
实战
下载显示进度
| |
下载重命名
| |
Reference
2.3.3 - shell-find
find
文件或目录查找
| |
-name
按文件名搜索
| |
-not或!
| |
-maxdepth
限制目录遍历深度
| |
-type
文件类型
| |
-perm
访问权限
| |
-user
查找属于特定用户
| |
-group
查找属于特定组
| |
-time
基于修改时间的检索
| |
-size
基于文件大小检索
| |
高级用法
xargs
与管道一起用
| |
失效情况,不是所有情况都可以用
| |
可以换成-exec
-exec
使用范围更广(推荐),不需要管道
| |
实战
将所有文件和目录都恢复成644和755
| |
删除30天以前的日志
| |
2.3.4 - shell文本处理三剑客
grep
grep 命令用于查找文件里符合条件的字符串。
语法
| |
egrep是grep命令的扩展
-a或--text
不要忽略二进制的数据,以文本文件方式搜索
-c
计算找到符合行的次数
--color
对查找到的文本颜色标红
-n 或 --line-number
在显示符合样式的那一行之前,标示出该行的列数编号。
| |
-v或--invert-match
反向选择,显示不包含匹配文本的所有行。
-h
查询多文件时不显示文件名
-i
不区分大小写
-l
查询多文件时只输出包含匹配字符的文件名
-v
取反
-s
不显示不存在或无匹配文本的错误信息
^和$
^表示以xx字符开头,$表示以什么结尾
| |
正则表达式
^:锚定行的开头
$:锚定行的结尾
".":点匹配一个非换行符的字符
"*":匹配零个或多个先前字符
".*":一起用代表任意字符
[]:匹配一个指定范围内的字符,如"[Gg]rep"匹配Grep和grep
[^]:匹配一个不再指定范围内的字符,
{}:匹配次数,{2}表示2次,{1-3}表示1到3次
实战
前后几行
| |
调试命令技巧
使用--color高亮查看grep命令的匹配结果
| |
去除#号行内容和空行
| |
匹配ip
| |
awk
AWK 是一种处理文本文件的语言,是一个强大的文本分析工具。
语法
| |
-F
指定分割字符(默认以空格为分割字符)
| |
-v
设置变量
| |
内建变量
$n
当前记录的第n个字段,字段间由FS分隔
$0
完整记录
FS
字段分隔符(默认是任何空格)
NF
表示一行记录的个数
NR
表示文本的行数(记录数)
实战
打印第x列
默认以空格为间隔
| |
打印本机ip
| |
sed
sed 命令是利用脚本来处理文本文件。
注意:sed里面有变量必须用双引号
语法
| |
-i
修改文件内容
| |
s 取代
字符替换
| |
| |
问题: 行尾逗号比较难处理
sed参考:
2.3.5 - shell解析json
背景
写一个自动安装程序的脚本,脚本需要查询接口最近的版本号从而拼接链接去下载固定的版本程序。但是之前解析json用的是jq,有一定的依赖性,虽然针对LInux没有安装jq脚本会帮忙自动安装,但是对于mac或其他平台没有做适配。鉴此,打算增加一个兜底逻辑,没有安装jq就自动解析字符串json
目标
解析json数组中第一个对象的固定字段。如下需要解析出"1.0.0.59":
| |
涉及命令
sed
主要做字符替换
全局字符替换
| |
grep
主要负责行查找
awk
主要做列解析
取第一行
| |
取第一列
| |
实战
脚本名为:parse_json.sh,文件名为json.txt
grep处理字符串和文件的区别
首先,解析出version行的内容。
grep处理多行文件和一行文件是有区别的。
获取version行命令
| |
如处理上文中多行文件输出:
| |
但如果使用以下命令将文件处理成一行:
| |
然后将执行 “获取version行命令” (修改文件名为json2.txt)
| |
此时输出的是整个一行文件。问题原因:grep是按行来区分的。
如果将多行文件内容硬编码在脚本里也是当做一行处理的!!错误效果同上
sed替换,为多行
解决上面问题需要用到 sed将“,”替换成“ ”,
| |
结果依然是一行文件,原因:grep不能识别其中的换行符。
echo -e识别换行符
可以借助 echo -e 识别换行符。
| |
或增加中间变量,效果相同:
| |
兼容一行或多行
不管一行多行将其先转成一行,在根据“,”转成多行
| |
这样不管文件内容如何多行或几行串在一行都不会影响最终解析结果,如1,2,3行一行,其余都是一行;或者version跟版本号不在一行 等等情况。
awk取第一行
不管文件内容如何变动,执行以下命令都能稳定获取结果
| |
结果为:
| |
sed按照:分割成两行
| |
删除第一行
| |
得到:
| |
sed去除"和空格
| |
得到最终结果
| |
暴力测试
将json字符串任意换行(不破坏引号内字符串内容),结果都是正确的
相关文件
parse_json.sh
Expand/Collapse Code Block
| |
json.txt
见本文开头
json2.txt
| |
运行脚本
| |
jq解析
安装
| |
解析json
| |
tips
sed替换[
| |
2.3.6 - 获取程序当前路径有多麻烦
背景
为了实现程序自己更新自己,需要获取当前程序的安装路径。
程序执行方式
程序可以通过如下几种方式来执行:
软链接
通过软链接执行程序
| |
相对路径
如以下命令:
| |
alias
通过在 .bash_profile或 .bash_rc 中配置 alias 的方式来执行
注意:脚本配置 alias 需要用 source 执行脚本而不是 bash 等,否则alias不会生效
| |
解决方案
针对以上三个类情况对文件安装目录进行解析,然后安装替换。
运行程序名称
通过os.Args[0] 来获取程序运行名称。
软链接方式
程序名称为输入的程序名称,如 util-cli
相对路径
同上,程序名称为输入的程序名称,如
| |
alias
程序名称为文件全路径,如
| |
which/type
可通过 which type 等命令来获取其软链地址
注意:
1、which、type 不可获取 alias 地址,尤其是 sh -c which/type xxx会报错
2、程序运行时,which、type获取不到alias的别名信息,与环境有关
pwd
通过pwd或者os.Getwd()来获取当前运行路径,然后拼接运行程序名获取全路径,可解决 相对路径 执行方式
核心解决思路
| |
该命令可解决 软链在其目录下 和 alias 的执行方式 其他情况可搭配 which pwd 处理
程序代码
Expand/Collapse Code Block
| |
2.4 - 进程
Introduction
进程相关
2.4.1 - Linux后台进程
背景
使用终端程序启动应用时,一旦退出命令行窗口,应用就会一起退出,无法继续运行。怎么将它变成系统的守护进程(daemon),成为一种服务(serive)?
前台/后台任务
变成守护进程的第一步,就是把它改成后台任务。
| |
只要在明星的尾部加上“&”,启动的进程就会成为后台任务。如果要让前台任务变为后台任务,可以先按 ctrl + z,然后执行 bg 命令(让最近一个暂停的后台任务继续执行)。 后台任务有两个特点:
1、继承当前 session 的标准输出和标准错误,因此后台任务的所有输出依然会同步地在命令行下显示
2、不再继承当前 session 的标注输入,无法向这个任务输入指令。如果它视图读取标注输入,就会暂停执行(halt)
可以看出,前台任务和后台任务的本质区别只要一个:是否继承标准输入。所以,执行后台任务的同时,用户还可以输入其他命令。
&忽略SIGINT信号
SIGHUP信号
Linux系统设计:
1、用户准备退出 session
2、系统向该 session 发出 SIGHUP 信号
3、session 将 SIGHUP 信号发给所有子进程
4、子进程收到 SIGHUP 信号后自动退出
这也解释了为什么前台任务会随着 session 的退出而退出,因为它收到 SIGHUP 信号。
那么,后台任务是否会收到 SIGHUP 信号呢?
这是由 Shell 的 huponexit 参数决定的,执行如下命令可看到该参数的值:
| |
大多数Linux系统,这个参数默认关闭(off),因此,session退出的时候,不会把 SIGHUP 信号发给后台任务。所以,一般后台任务不会随着session一起退出。
disown命令
通过后台任务启动守护进程并不保险,因为有的系统的 huponexit 参数可能是打开的(on)。
更保险的方法是使用 disown 命令,它可以将指定任务从**后台任务列表(jobs 命令返回的结果)**之中移除。一个后台任务只要不在这个列表中,session肯定就不会向它发出 SIGHUP 信号。
| |
执行该命令后,正在执行的后台进程就被移出了后台任务列表。可以执行jobs命令验证,输出结果里面,不会有这个进程。
disown 命令使用说明
| |
标准I/O
使用disown命令之后,还有一个问题。那就是,退出 session 以后,如果后台进程与标准I/O有交互,它还是会挂掉。
举例,后台进程访问后有log输出。按照以下命令运行,退出session,访问进程会发现连接不上
| |
这是因为后台任务的标准I/O继承自当前session,disown 命令并没有改变这一点。一旦后台任务读写标准I/O,就会发现它已经不存在,所以会报错终止执行。 为了解决该问题,需要对后台任务的标准I/O进行重定向。
重定向
| |
nohup命令
比 disown 更方便的命令,就是 nohup
| |
nohup 命令对进程做了三件事 1、组织 SIGHUP 信号发到这个进程
2、关闭标准输入。该进程不再能够接收任何输入,即使运行在前台
3、重定向标准输出和标准错误到文件 nohup.out
即,nohup 命令实际上将子进程与它所在的session分离了。
注意:nohup 命令不会自动把任务变为后台任务,所以必须加上 & 符号
nohup和&比较
no hangup的缩写,意即“不挂断”
| |
Screen/Tmux命令
Screen
另一种思路是使用 terminal multiplexer (终端复用器:在同一个终端里面,管理多个session),典型的就是 Screen 命令和 Tmux 命令。
它们可以在当前 session 里面,新建另一个 session。这样的话,当前 session 一旦结束,不影响其他 session。而且,以后重新登录,还可以再连上早先新建的 session。
Screen 的用法如下:
| |
然后,按下ctrl + A和ctrl + D,回到原来的 session,从那里退出登录。下次登录时,再切回去。
| |
如果新建多个后台 session,就需要为它们指定名字。
| |
如果要停掉某个 session,可以先切回它,然后按下ctrl + c和ctrl + d。
Tmux
Tmux 比 Screen 功能更多、更强大,它的基本用法如下。
| |
除了tmux detach,另一种方法是按下Ctrl + B和d ,也可以回到原来的 session。
| |
如果新建多个 session,就需要为每个 session 指定名字。
| |
Node工具
对于 Node 应用来说,可以不用上面的方法,有一些专门用来启动的工具:forever,nodemon 和 pm2。
forever
forever 的功能很简单,就是保证进程退出时,应用会自动重启。
Expand/Collapse Code Block
| |
nodemon
nodemon一般只在开发时使用,它最大的长处在于 watch 功能,一旦文件发生变化,就自动重启进程。
| |
pm2
pm2 的功能最强大,除了重启进程以外,还能实时收集日志和监控。
Expand/Collapse Code Block
| |
Systemd
见 Systemd 专栏
Reference
2.4.2 - Linux的启动过程
概述

1、加载内核
操作系统接管硬件以后,首先读入 /boot 目录下的内核文件。
| |
2、启动初始化进程
内核文件加载以后,就开始运行第一个程序 /sbin/init,它的作用是初始化系统环境。
进程编号(pid)就是1。其他所有进程都从它衍生,都是它的子进程
3、确定运行级别
许多程序需要开机启动。它们在Windows叫做"服务"(service),在Linux就叫做守护进程(daemon)。
Linux预置七种运行级别(0-6)。一般来说,0是关机,1是单用户模式(也就是维护模式),6是重启。运行级别2-5,各个发行版不太一样,对于Debian来说,都是同样的多用户模式(也就是正常模式)。
init进程首先读取文件 /etc/inittab,它是运行级别的设置文件。如果你打开它,可以看到第一行是这样的:
| |
表明系统启动时的运行级别为2。 运行级别为2的程序清单在/etc目录下面
| |
"rc",表示run command(运行程序)、d表示directory(目录)
| |
除了第一个文件README以外,其他文件名都是"字母S+两位数字+程序名"的形式。 S表示Start,K表示Kill。
如果想修改程序不建议修改该目录,可以参考:Debian Admin - Manage init 和 Debian Admin - Remove Services
4、加载开机启动程序
如果有多个运行级别的程序需要启动,为了避免拷贝,设置文件链接指向目录 /etc/init.d ,真正的启动脚本都统一放在这个目录中。init进程会运行这个目录里的启动脚本逐一加载开机启动程序。
| |
另外一个好处,直接使用 /etc/init.d下的链接文件直接启动或者关闭程序,不必到真实路径下查找。如:
| |
/etc/init.d 这个目录名最后一个字母d,表示目录(directory),用来与程序 /etc/init 区分。
5、用户登录
用户的登录方式有三种:
1)命令行登录
init进程调用getty程序(意为get teletype),让用户输入用户名和密码。输入完成后,再调用login程序,核对密码(Debian还会再多运行一个身份核对程序/etc/pam.d/login)。如果密码正确,就从文件 /etc/passwd 读取该用户指定的shell,然后启动这个shell。
2)ssh登录
这时系统调用sshd程序(Debian还会再运行/etc/pam.d/ssh ),取代getty和login,然后启动shell。
3)图形界面登录
init进程调用显示管理器,Gnome图形界面对应的显示管理器为gdm(GNOME Display Manager),然后用户输入用户名和密码。如果密码正确,就读取/etc/gdm3/Xsession,启动用户的会话。
6、进入 login shell
shell就是命令行界面,让用户可以直接与操作系统对话。用户登录时打开的shell,就叫做login shell。
1)命令行登录
首先读入 /etc/profile,这是对所有用户都有效的配置;然后依次寻找下面三个文件,这是针对当前用户的配置。
| |
注意:这三个文件只要有一个存在,就不再读入后面的文件。 2)ssh登录:
与命令行登录完全相同。
3)图形界面登录
只加载 /etc/profile 和 ~/.profile。也就是说,~/.bash_profile 不管有没有,都不会运行。
7、打开 non-login shell
用户进入操作系统以后,常常会再手动开启一个shell。这个shell就叫做 non-login shell,意思是它不同于登录时出现的那个shell,不读取/etc/profile和.profile等配置文件。
non-login shell 会读入用户自己的bash配置文件 ~/.bashrc。
如果不进入 non-login shell,.bashrc会通过 ~/.profile文件运行,如下.profile文件内容:
| |
上面代码先判断变量 $BASH_VERSION 是否有值,然后判断主目录下是否存在 .bashrc 文件,如果存在就运行该文件。第三行开头的那个点,是source命令的简写形式,表示运行某个文件,写成"source ~/.bashrc"也是可以的。 因此,只要运行~/.profile文件,~/.bashrc文件就会连带运行。但是上一节的第一种情况提到过,如果存在~/.bash_profile文件,那么有可能不会运行~/.profile文件。解决这个问题很简单,把下面代码写入.bash_profile就行了。
| |
这样一来,不管是哪种情况,.bashrc都会执行,用户的设置可以放心地都写入这个文件了。 Bash的设置之所以如此繁琐,是由于历史原因造成的。早期的时候,计算机运行速度很慢,载入配置文件需要很长时间,Bash的作者只好把配置文件分成了几个部分,阶段性载入。系统的通用设置放在 /etc/profile,用户个人的、需要被所有子进程继承的设置放在.profile,不需要被继承的设置放在.bashrc。
顺便提一下,除了Linux以外, Mac OS X 使用的shell也是Bash。但是,它只加载 .bash_profile,然后在.bash_profile里面调用.bashrc。而且,不管是ssh登录,还是在图形界面里启动shell窗口,都是如此。
Reference
2.4.3 - supervisor
简介
Supervisor是用Python开发的一套通用的进程管理程序,能将一个普通的命令行进程变为后台daemon,并监控进程状态,异常退出时能自动重启。它是通过fork/exec的方式把这些被管理的进程当作supervisor的子进程来启动,这样只要在supervisor的配置文件中,把要管理的进程的可执行文件的路径写进去即可。也实现当子进程挂掉的时候,父进程可以准确获取子进程挂掉的信息的,可以选择是否自己启动和报警。supervisor还提供了一个功能,可以为supervisord或者每个子进程,设置一个非root的user,这个user就可以管理它对应的进程。
supervisor安装
yum源
| |
Debian/Ubuntu
| |
pip安装
| |
easy_install安装
| |
supervisor使用
supervisor配置文件:/etc/supervisord.conf
注:supervisor的配置文件默认是不全的,不过在大部分默认的情况下,上面说的基本功能已经满足。
子进程配置文件路径:/etc/supervisord.d/
注:默认子进程配置文件为ini格式,可在supervisor主配置文件中修改。
配置文件说明
supervisor.conf配置文件说明
Expand/Collapse Code Block
| |
子进程配置文件说明
给需要管理的子进程(程序)编写一个配置文件,放在/etc/supervisor.d/目录下,以.ini作为扩展名(每个进程的配置文件都可以单独分拆也可以把相关的脚本放一起)。如任意定义一个和脚本相关的项目名称的选项组(/etc/supervisord.d/test.conf):
Expand/Collapse Code Block
| |
子进程配置示例
| |
supervisor命令说明
| |
注意事项:
使用supervisor进程管理命令之前先启动supervisord,否则程序报错。
使用命令supervisord -c /etc/supervisord.conf启动。
若是centos7:
| |
2.4.4 - systemd
背景
Linux的启动一直是采用 init 进程
如以下命令
| |
该方法有如下缺点 1、启动时间长。init进程是串行启动。
2、启动脚本复杂。init进程只是执行启动脚本,不管其他事情。脚本需要自己处理各种情况,使得脚本变得很长。
简述
Systemd 就是为了解决这些问题而诞生的。它的设计目标是,为系统的启动和管理提供一套完整的解决方案。
根据 Linux 惯例,字母d是守护进程(daemon)的缩写。 Systemd 这个名字的含义,就是它要守护整个系统。
使用了 Systemd,就不需要再用init了。Systemd 取代了initd,成为系统的第一个进程(PID 等于 1),其他进程都是它的子进程。
Systemd 是 Linux 系统工具,用来启动守护进程,已成为大多数发行版的标准配置。
Systemd 的优点是功能强大,使用方便,缺点是体系庞大,非常复杂。与操作系统的其他部分强耦合,违反"keep simple, keep stupid"的Unix 哲学。("简单原则"——尽量用简单的方法解决问题——是"Unix哲学"的根本原则。这也就是著名的KISS(keep it simple, stupid),意思是"保持简单和笨拙"。)
| |
systemd架构图

系统管理
systemd不是一个命令,而是一组命令,涉及到系统管理的方方面面。
systemctl
主命令,用于管理系统。
| |
systemd-analyze
用于查看启动耗时。
| |
hostnamectl
用于查看当前主机的信息。
| |
localectl
用户查看本地化设置
| |
timedatectl
用于查看当前时区设置
| |
loginctl
用于查看当前登录的用户
| |
Unit
含义
Systemd可以管理所有系统资源,不同的系统的资源统称为Unit(单位)。
Unit一共分为12种
| |
Unit查询
| |
Unit状态
| |
查询状态 可以供脚本内部的判断语句使用。
| |
Unit管理
对于用户来说,最常用的是下面这些命令,用于启动和停止 Unit(主要是 service)。
| |
Unit依赖关系
Unit 之间存在依赖关系:A 依赖于 B,就意味着 Systemd 在启动 A 的时候,同时会去启动 B。
| |
上面命令的输出结果之中,有些依赖是 Target 类型(详见下文),默认不会展开显示。如果要展开 Target,就需要使用--all参数。
| |
Unit配置文件
每个Unit都有一个配置文件,告诉Systemd怎么启动Unit。
Systemd 默认从目录/etc/systemd/system/读取配置文件。但是,里面存放的大部分文件都是符号链接,指向目录/usr/lib/systemd/system/,真正的配置文件存放在那个目录。
systemctl enable命令用于在上面两个目录之间,建立符号链接关系。
| |
如果配置文件里面设置了开机启动,systemctl enable命令相当于激活开机启动。 与之对应的,systemctl disable命令用于在两个目录之间,撤销符号链接关系,相当于撤销开机启动。
| |
配置文件的后缀名,就是该 Unit 的种类,比如sshd.socket。如果省略,Systemd 默认后缀名为.service,所以sshd会被理解成sshd.service。
配置文件状态
| |
状态一共分为四种:
| |
一旦修改配置文件,就要Systemd重新加载配置文件,然后重新启动,否则修改不会生效。
| |
配置文件格式
配置文件为普通的文本文件。
| |
配置文件分成几个区块。每个区块的第一行,是用方括号表示的区别名。 每个区块内部都是一些等号连接的键值对。
注意:
1、区块名和字段名,都是大小写敏感的。
2、键值对的等号两侧不能有空格。
配置文件的区块
Unit 配置文件的完整字段清单,可以参考官方文档。
Unit区块
通常是配置文件的第一个区块。
用来定义 Unit 的元数据,以及配置与其他 Unit 的关系。主要字段如下:
| |
Install区块
通常是配置文件的最后一个区块,用来定义如何启动,以及是否开启启动等。主要字段如下:
| |
Service区块
用来 Service 的配置,只有 Service 类型的 Unit 才有这个区块。主要字段如下:
| |
Target
启动计算机的时候,需要启动大量的 Unit。如果每一次启动,都要一一写明本次启动需要哪些 Unit,显然非常不方便。Systemd 的解决方案就是 Target。
简单说,Target 就是一个 Unit 组,包含许多相关的 Unit 。启动某个 Target 的时候,Systemd 就会启动里面所有的 Unit。从这个意义上说,Target 这个概念类似于"状态点",启动某个 Target 就好比启动到某种状态。
传统的init启动模式里面,有 RunLevel 的概念,跟 Target 的作用很类似。不同的是,RunLevel 是互斥的,不可能多个 RunLevel 同时启动,但是多个 Target 可以同时启动。
| |
Target 与 传统 RunLevel 的对应关系如下。
| |
它与init进程的主要差别如下: (1)默认的 RunLevel(在/etc/inittab文件设置)现在被默认的 Target 取代,位置是/etc/systemd/system/default.target,通常符号链接到graphical.target(图形界面)或者multi-user.target(多用户命令行)。
(2)启动脚本的位置,以前是/etc/init.d目录,符号链接到不同的 RunLevel 目录 (比如/etc/rc3.d、/etc/rc5.d等),现在则存放在/lib/systemd/system和/etc/systemd/system目录。
(3)配置文件的位置,以前init进程的配置文件是/etc/inittab,各种服务的配置文件存放在/etc/sysconfig目录。现在的配置文件主要存放在/lib/systemd目录,在/etc/systemd目录里面的修改可以覆盖原始设置。
日志管理
Systemd 统一管理所有 Unit 的启动日志。带来的好处就是,可以只用journalctl一个命令,查看所有日志(内核日志和应用日志)。日志的配置文件是/etc/systemd/journald.conf。
Expand/Collapse Code Block
| |
Reference
2.4.5 - goreman
概述
Linux下多进程管理工具对开发和运维都很有用,常见的功能全面的主流工具主要有monit、supervisor。不过开发中使用则推荐轻量级小工具 goreman。
goreman 是对 Ruby 下广泛使用的 foreman 的重写,毕竟基于golang的工具简单易用很多。
goreman的作者是mattn,在golang社区挺活跃的日本的一名程序员。foreman原作者也实现了一个golang版:forego,不过没有goreman好用,举个例子:coreos的etcd就是使用的goreman来一键启停单机版的etcd集群。
安装
前提:
- 先安装好 go 环境
- 将 $GOPATH/bin 添加到 $PATH
go 工具安装都比较简单:
| |
使用
善用 goreman help
- 新建一个 Procfile 文件,如果改名则需要
goreman -f指定 - 在包含 Procfile 的目录下执行:
goreman start - 关闭时直接 ctrl + c 退出,goreman 会自动把所有启动的进程都 shut down
举例
kafka
以 Apache kafka 的使用为例,了解的朋友应该知道,kafka 使用时通常需要启动两个进程:zookeeper 和 kafka broker,因此可以编写一个 kafka 开发环境的 Procfile:
| |
然后执行 goreman start ,可以看到不同颜色区分的 zookeeper、kafka broker 进程的启动日志。
关闭时,直接 ctrl + c,则两个 bash 进程也会被自动关闭。
etcd raftexample
| |
高级用法
上述是最简单的使用场景:直接使用 goreman start,不过有个缺点,即 goreman 绑定到了当前的 session,而且不能灵活控制多个进程启停以及顺序。而实际开发过程中,通常需要经常单独启停某个正在开发的模块相关的进程,比如上面例子中的 kafka-broker,而 Zookeeper 通常不需要频繁启停。
可以使用更高级的 goreman run 命令来实现,如:
| |
小结
多进程管理是目前开发尤其是互联网web、服务器后端很常用的工具,尤其上云之后,云应用普遍推崇的 microservices 微服务架构进一步增加了后端进程数。而 goreman 很适合开发环境使用,能够一键式管理多个后台进程,并及时清理环境。不过真正的生产环境,还是使用monit/m、supervisor 等更成熟稳定、功能全面的多进程管理工具。
2.4.6 - 守护进程
简述
守护进程(daemon)是生存期长的一种进程,没有控制终端。它们常常在系统引导装入时启动,仅在系统关闭时才终止。UNIX系统有很多守护进程,守护进程程序的名称通常以字母“d”结尾:例如,syslogd 就是指管理系统日志的守护进程。通过ps进程查看器 ps -efj 的输出实例,内核守护进程的名字出现在方括号中,大致输出如下:
基本概念
进程组
- 每个进程除了有一个进程ID之外,还属于一个进程组
- 进程组是一个或多个进程的集合,同一进程组中的各进程接收来自同一终端的各种信号
- 每个进程组有一个组长进程。组长进程的进程组ID等于其进程ID
会话(session)是一个或多个进程组的集合,进程调用 setsid 函数(原型:pid_t setsid(void) )建立一个会话。
进程调用 setsid 函数建立一个新会话,如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话。具体会发生以下3件事:
- 该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。此时,该进程是新会话的唯一进程。
- 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID
- 该进程没有控制终端。如果调用setsid之前该进程有一个控制终端,那么这种联系也被切断
控制终端(Controlling Terminal)
每个会话可以有一个单独的控制终端,与控制终端连接的 Leader 就是控制进程(Controlling Process)。
进程分类
子进程child thread:
相对父进程而言, 父进程创建的进程, 子进程只能对应一个父进程。如果没有标记为daemon , 则杀死父进程不会对子进程的运行状态有丝毫影响。
守护进程daemon thread:
即daemon thread,是子进程的一种状态,标记子进程与父进程一起结束。
僵尸进程:
本该结束,但仍在后台运行的子进程。因为某些子进程没有设置daemon 属性,如果杀死父进程,其子进程将会变成“僵尸进程”。僵尸进程的父进程将成为init 进程的子进程。
基本操作
查看守护进程
| |
如何编写守护进程
可参考《unix环境高级编程》第13章 守护进程
如何使普通进程达到守护进程的部分效果
参考 Linux后台进程 专栏
| |
nohup忽略SIGHUP信号,&忽略SIGINT信号。
守护进程的创建
fork
守护进程的父进程是 init 进程,在创建时先从父进程 fork 出来一个子进程,退出父进程,这时子进程变成孤儿,就成了 init 的子进程。
子进程会继承父进程的会话,进程组,控制终端,文件描述符等。
setid
通过setid()来创建新会话,同时也脱离了原来的进程组,会话以及控制终端,成为新的会话的组长。此时它可能会再申请一个控制终端,所以我们再 fork 一下,并只保留新的子进程,这样就不是会话组长了,就不能申请控制终端了。
close(fd)
之后再关闭从父进程继承的文件描述符。至少要关闭 0,1,2 这三个文件描述符,分别对应了 stdin, stdout, 和 stderr。不过通常用 sysconf(_SC_OPEN_MAX) 获取系统允许的最大文件描述符个数,然后全部 close 掉。
关闭之后我们要将文件描述符 0,1,2 重新定向到 "/dev/null",防止新打开的文件的文件描述符为 0,1,2。
umask(0)
设置文件掩码是为了不受父进程的 umask 的影响,能自由创建读写文件和目录。
chdir("/")
守护进程一般是一直执行到系统关机,在它运行过程中,它所在的目录就不能卸载(unmounted)。通过将它的工作目录转移到根目录,用来的目录就允许卸载了。也不一定要根目录(这种情况,运行需要超级权限),可以选择一个不需要卸载的路径。
对比
守护进程与后台进程的区别
1、守护进程已经完全脱离终端控制台了,而后台程序并未完全脱离终端(在终端未关闭前还是会往终端输出结果);
2、守护进程在关闭终端控制台时不会受影响,而后台程序会随用户退出而停止,需要在启动命令后加上“&”格式运行才能避免影响;
3、守护进程的会话组和当前目录、文件描述符都是独立的。后台进程运行只是终端进行了一次fork,仅仅让程序在后台执行而已。
Reference
2.4.7 - 进程间通信
简述
IPC(Inter-Process Communication,进程间通信)。进程间通信是指两个进程的数据之间产生交互
进程间通信方式
有如下几种方式:
- 管道/匿名管道(pipe)
- 命名管道(FIFO)
- 消息队列
- 共享内存
- 信号量
- 信号
- 套接字
管道/匿名管道(pipe)
匿名管道(Anonymous Pipes),即将多个命令串起来的竖线,背后的原理到底是什么。
如
| |
创建管道,系统调用
| |
创建管道 pipe,返回了两个文件描述符,表示管道的两端,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。

所谓的匿名管道,其实就是内核里面的一串缓存。
无论是匿名管道,还是命名管道,在内核都是一个文件。只要是文件就要有一个 inode。这里又用到了特殊 inode、字符设备、块设备,其实都是这种特殊的 inode。
在这种特殊的 inode 里面,file_operations 指向管道特殊的 pipefifo_fops,这个 inode 对应内存里面的缓存。
当用文件的 open 函数打开这个管道设备文件的时候,会调用 pipefifo_fops 里面的方法创建 struct file 结构,inode 指向特殊的 inode,也对应内存里面的缓存,file_operations 也指向管道特殊的 pipefifo_fops。
写入一个 pipe 就是从 struct file 结构找到缓存写入,读取一个 pipe 就是从 struct file 结构找到缓存读出。

命名管道(FIFO)
通过 mkdifo 命令显示创建
| |
测试
| |
消息队列
结构体
| |
创建消息队列
Expand/Collapse Code Block
| |
运行上面的程序
| |
msgget 函数; ftok(file to key)会根据文件的 inode 生成近乎唯一的 key。
这些都属于 System V IPC 进程间通信机制体系。
System V IPC 体系有一个统一的命令行工具:ipcmk,ipcs 和 ipcrm 用于创建、查看和删除 IPC 对象。
| |
发送消息
Expand/Collapse Code Block
| |
发送消息主要调用 msgsnd 函数。第一个参数是 message queue 的 id,第二个参数是消息的结构体,第三个参数是消息的长度,最后一个参数是 flag。这里 IPC_NOWAIT 表示发送的时候不阻塞,直接返回。 getopt_long、do-while 循环以及 switch,是用来解析命令行参数的。命令行参数的格式定义在 long_options 里面。每一项的第一个成员“id”“type”“message”是参数选项的全称,第二个成员都为 1,表示参数选项后面要跟参数,最后一个成员’i’‘t’'m’是参数选项的简称。
编译并运行这个发送程序
| |
接收消息
Expand/Collapse Code Block
| |
收消息主要调用 msgrcv 函数,第一个参数是 message queue 的 id,第二个参数是消息的结构体,第三个参数是可接受的最大长度,第四个参数是消息类型, 最后一个参数是 flag,这里 IPC_NOWAIT 表示接收的时候不阻塞,直接返回。 编译并运行这个发送程序。可以看到,如果有消息,可以正确地读到消息;如果没有,则返回没有消息。
| |
共享内存(share memory)
共享内存也是 System V IPC 进程间通信机制体系中的。
创建共享内存
创建一个共享内存,调用 shmget。在这个体系中,创建一个 IPC 对象都是 xxxget,第一个参数是 key,和 msgget 里面的 key 一样,都是唯一定位一个共享内存对象,也可以通过关联文件的方式实现唯一性。第二个参数是共享内存的大小。第三个参数如果是 IPC_CREAT,同样表示创建一个新的。
| |
创建完毕之后,可以通过 ipcs 命令查看这个共享内存。
| |
访问共享内存
一个进程想要访问这一段共享内存,需要将内存加载到虚拟地址空间的某个位置,通过 shmat 函数,就是 attach 的意思。其中 addr 就是要指定 attach 到这个地方。这个地址的设定难度比较大,除非对于内存布局非常熟悉,否则可能会 attach 到一个非法地址。通常的做法是将 addr 设为 NULL,让内核选一个合适的地址。返回值就是真正被 attach 的地方。
| |
删除共享内存
共享内存使用完毕,可以通过 shmdt 解除绑定,然后通过 shmctl,将 cmd 设置为 IPC_RMID,从而删除这个共享内存对象。
| |
共享内存的创建和映射小结
1、调用 shmget 创建共享内存。
2、先通过 ipc_findkey 在基数树中查找 key 对应的共享内存对象 shmid_kernel 是否已经被创建过,如果已经被创建,就会被查询出来,例如 producer 创建过,在 consumer 中就会查询出来。
3、如果共享内存没有被创建过,则调用 shm_ops 的 newseg 方法,创建一个共享内存对象 shmid_kernel。例如,在 producer 中就会新建。
4、在 shmem 文件系统里面创建一个文件,共享内存对象 shmid_kernel 指向这个文件,这个文件用 struct file 表示,我们姑且称它为 file1。
5、调用 shmat,将共享内存映射到虚拟地址空间。
6、shm_obtain_object_check 先从基数树里面找到 shmid_kernel 对象。
7、创建用于内存映射到文件的 file 和 shm_file_data,这里的 struct file 我们姑且称为 file2。
8、关联内存区域 vm_area_struct 和用于内存映射到文件的 file,也即 file2,调用 file2 的 mmap 函数。
9、file2 的 mmap 函数 shm_mmap,会调用 file1 的 mmap 函数 shmem_mmap,设置 shm_file_data 和 vm_area_struct 的 vm_ops。
10、内存映射完毕之后,其实并没有真的分配物理内存,当访问内存的时候,会触发缺页异常 do_page_fault。
11、vm_area_struct 的 vm_ops 的 shm_fault 会调用 shm_file_data 的 vm_ops 的 shmem_fault。
12、在 page cache 中找一个空闲页,或者创建一个空闲页。
信号量(semaphore)
如果两个进程 attach 同一个共享内存,很有可能冲突。
需要一种保护机制,使得同一个共享的资源,同时只能被一个进程访问。在 System V IPC 进程间通信机制体系中,早就想好了应对办法,就是信号量(Semaphore)。因此,信号量和共享内存往往要配合使用(常用模式)。
信号量其实是一个计数器,主要用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
将信号量初始化为一个数值,来代表某种资源的总体数量。对于信号量来讲,会定义两种原子操作,一个是P 操作,我们称为申请资源操作。这个操作会申请将信号量的数值减去 N,表示这些数量被他申请使用了,其他人不能用了。另一个是V 操作,我们称为归还资源操作,这个操作会申请将信号量加上 M,表示这些数量已经还给信号量了,其他人可以使用了。
创建信号量
创建信号量可以通过 semget 函数(都是 xxxget)。第一个参数 key 也是类似的,第二个参数 num_sems 不是指资源的数量,而是表示可以创建多少个信号量,形成一组信号量,也就是说,如果你有多种资源需要管理,可以创建一个信号量组。
| |
接下来初始化信号量的总的资源数量。通过 semctl 函数,第一个参数 semid 是这个信号量组的 id,第二个参数 semnum 才是在这个信号量组中某个信号量的 id,第三个参数是命令,如果是初始化,则用 SETVAL,第四个参数是一个 union。如果初始化,应该用里面的 val 设置资源总量。
| |
pv操作
无论是 P 操作还是 V 操作,统一用 semop 函数。第一个参数还是信号量组的 id,一次可以操作多个信号量。第三个参数 numops 就是有多少个操作,第二个参数将这些操作放在一个数组中。
数组的每一项是一个 struct sembuf,里面的第一个成员是这个操作的对象是哪个信号量。
第二个成员就是要对这个信号量做多少改变。如果 sem_op < 0,就请求 sem_op 的绝对值的资源。如果相应的资源数可以满足请求,则将该信号量的值减去 sem_op 的绝对值,函数成功返回。
当相应的资源数不能满足请求时,就要看 sem_flg 了。如果把 sem_flg 设置为 IPC_NOWAIT,也就是没有资源也不等待,则 semop 函数出错返回 EAGAIN。如果 sem_flg 没有指定 IPC_NOWAIT,则进程挂起,直到当相应的资源数可以满足请求。若 sem_op > 0,表示进程归还相应的资源数,将 sem_op 的值加到信号量的值上。如果有进程正在休眠等待此信号量,则唤醒它们。
| |
信号量和共享内存都比较复杂,两者还要结合起来用,就更加复杂,它们内核的机制就更加复杂。
信号量的机制小结

1、调用 semget 创建信号量集合。
2、ipc_findkey 会在基数树中,根据 key 查找信号量集合 sem_array 对象。如果已经被创建,就会被查询出来。例如 producer 被创建过,在 consumer 中就会查询出来。
3、如果信号量集合没有被创建过,则调用 sem_ops 的 newary 方法,创建一个信号量集合对象 sem_array。例如,在 producer 中就会新建。
4、调用 semctl(SETALL) 初始化信号量。
5、sem_obtain_object_check 先从基数树里面找到 sem_array 对象。
6、根据用户指定的信号量数组,初始化信号量集合,也即初始化 sem_array 对象的 struct sem sems[]成员。
7、调用 semop 操作信号量。
8、创建信号量操作结构 sem_queue,放入队列。
9、创建 undo 结构,放入链表。
共享内存和信号量
共享内存和信号量的配合机制
- 无论是共享内存还是信号量,创建与初始化都遵循同样流程,通过 ftok 得到 key,通过 xxxget 创建对象并生成 id;
- 生产者和消费者都通过 shmat 将共享内存映射到各自的内存空间,在不同的进程里面映射的位置不同;
- 为了访问共享内存,需要信号量进行保护,信号量需要通过 semctl 初始化为某个值;
- 接下来生产者和消费者要通过 semop(-1) 来竞争信号量,如果生产者抢到信号量则写入,然后通过 semop(+1) 释放信号量,如果消费者抢到信号量则读出,然后通过 semop(+1) 释放信号量;
- 共享内存使用完毕,可以通过 shmdt 来解除映射。

信号(Signal)
上面讲的进程间通信的方式,都是常规状态下的工作模式,对应到咱们平时的工作交接,收发邮件、联合开发等,其实还有一种异常情况下的工作模式。
例如出现线上系统故障,这个时候,什么流程都来不及了,不可能发邮件,也来不及开会,所有的架构师、开发、运维都要被通知紧急出动。所以,7 乘 24 小时不间断执行的系统都需要有告警系统,一旦出事情,就要通知到人,哪怕是半夜,也要电话叫起来,处理故障。
对应到操作系统中,就是信号。信号没有特别复杂的数据结构,就是用一个代号一样的数字。Linux 提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片,对于紧急的行动,都是说,“1 号作战任务”开始执行,警察就开始行动了。情况紧急,不能啰里啰嗦了。
信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
所有信号
| |
可以通过 man 7 signal 命令查看,Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Signal Value Action Comment
──────────────────────────────────────────────────────────────────────
SIGHUP 1 Term Hangup detected on controlling terminal
or death of controlling process
SIGINT 2 Term Interrupt from keyboard
SIGQUIT 3 Core Quit from keyboard
SIGILL 4 Core Illegal Instruction
SIGABRT 6 Core Abort signal from abort(3)
SIGFPE 8 Core Floating point exception
SIGKILL 9 Term Kill signal
SIGSEGV 11 Core Invalid memory reference
SIGPIPE 13 Term Broken pipe: write to pipe with no
readers
SIGALRM 14 Term Timer signal from alarm(2)
SIGTERM 15 Term Termination signal
SIGUSR1 30,10,16 Term User-defined signal 1
SIGUSR2 31,12,17 Term User-defined signal 2
……
每个信号都有唯一的 ID,还有遇到信号的默认操作(Action)。
信号处理方式
一旦有信号产生,用户进程对信号的处理方式:
1、执行默认操作。即上面列表中的 Action。如 Term 是终止进程的意思。 Core 是 Core Dump,终止进程后,通过Core Dump 将当前进程的运行状态保存在文件里面,方便事后分析。
2、捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3、忽略信号。但有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。
信号处理流程
信号处理最常见的流程。这个过程主要是分成两步,第一步是注册信号处理函数。第二步是发送信号。
信号注册
signal
如果不想让某个信号执行默认操作,一种方法就是对特定的信号注册相应的信号处理函数,设置号处理方式的是 signal 函数。
| |
其实就是定义一个方法,并且将这个方法和某个信号关联起来。当这个进程遇到这个信号的时候,就执行这个方法。 signal 的 glibc 实现
| |
sa_flags 进行了默认的设置。SA_ONESHOT 意思就是设置的信号处理函数,仅仅起作用一次。用完了一次就设置回默认行为。 另外一个设置 SA_NOMASK。通过 __sigemptyset,将 sa_mask 设置为空。表示在这个信号处理函数执行过程中,如果再有其他信号(哪怕相同的信号),这个信号处理函数会被中断。
例如,如果是相同的信号,很可能操作的是同一个实例,同步、死锁等问题。一般的思路:某个信号的信号处理函数运行的时候,暂时屏蔽这个信号。屏蔽并不意味着信号一定丢失,而是暂存,这样能够做到信号处理函数对于相同的信号,处理完一个再处理下一个,信号处理函数的逻辑要简单很多。
还有一个设置就是设置了 SA_INTERRUPT,清除了 SA_RESTART。信号的到来时间是不可预期的,有可能程序正在调用某个漫长的系统调用的时候(可以在一台 Linux 机器上运行 man 7 signal 命令,在这里找 Interruption of system calls and library functions by signal handlers 的部分,里面说得非常详细),这个时候一个信号来了,会中断这个系统调用,去执行信号处理函数,那执行完后系统调用怎么处理?
有两种处理方法:一种是 SA_INTERRUPT,即系统调用被中断了,不再重试这个系统调用了,而是直接返回一个 -EINTR 常量,告诉调用方系统调用被信号中断了,但是怎么处理你看着办。这样调用方可以根据自己的逻辑,重新调用或直接返回,这会使得代码非常复杂,在所有系统调用的返回值判断里面,都要特殊判断一下这个值。
另外一种处理方法是 SA_RESTART。这个时候系统调用会被自动重新启动,不需要调用方自己写代码。(当然也可能存在问题,例如从终端读入一个字符'a',处理'a'字符的时候被信号中断了,等信号处理完毕再次读入一个字符时,如果用户不再输入就阻塞,需要用户再次输入同一个字符。)
因此建议使用 sigaction 函数,根据需要定制参数。
sigaction
如果在 Linux 下面执行 man signal 的话,会发现 Linux 不建议直接用这个方法,而是改用 sigaction。定义如下:
| |
其实它还是将信号和一个动作进行关联,只不过这个动作由一个结构 struct sigaction 表示了。
| |
和 signal 类似的是,这里面有 __sighandler_t。但是,其他成员变量可以更加细致地控制信号处理的行为。而 signal 函数没有机会设置这些。这里需要注意的是,signal 不是系统调用,而是 glibc 封装的一个函数。这样就像 man signal 里面写的一样,不同的实现方式,设置的参数会不同,会导致行为的不同。 glibc 里面有个文件 syscalls.list。定义了库函数调用哪些系统调用,这里包含 sigaction。
glibc 中,__sigaction 会调用 __libc_sigaction,并最终调用的系统调用是 rt_sigaction。
Expand/Collapse Code Block
| |
我们的库函数虽然调用的是 sigaction,到了系统调用层,调用的可不是系统调用 sigaction,而是系统调用 rt_sigaction。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SYSCALL_DEFINE4(rt_sigaction, int, sig,
const struct sigaction __user *, act,
struct sigaction __user *, oact,
size_t, sigsetsize)
{
struct k_sigaction new_sa, old_sa;
int ret = -EINVAL;
......
if (act) {
if (copy_from_user(&new_sa.sa, act, sizeof(new_sa.sa)))
return -EFAULT;
}
ret = do_sigaction(sig, act ? &new_sa : NULL, oact ? &old_sa : NULL);
if (!ret && oact) {
if (copy_to_user(oact, &old_sa.sa, sizeof(old_sa.sa)))
return -EFAULT;
}
out:
return ret;
}
在 rt_sigaction 里面将用户态的 struct sigaction 结构,拷贝为内核态的 k_sigaction,然后调用 do_sigaction。进程内核的数据结构里 struct task_struct 里面有一个成员 sighand,里面有一个 action。这是一个数组,下标是信号,内容就是信号处理函数,do_sigaction 就是设置 sighand 里的信号处理函数。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
struct task_struct *p = current, *t;
struct k_sigaction *k;
sigset_t mask;
......
k = &p->sighand->action[sig-1];
spin_lock_irq(&p->sighand->siglock);
if (oact)
*oact = *k;
if (act) {
sigdelsetmask(&act->sa.sa_mask,
sigmask(SIGKILL) | sigmask(SIGSTOP));
*k = *act;
......
}
spin_unlock_irq(&p->sighand->siglock);
return 0;
}
至此,信号处理函数的注册已经完成了。 信号注册小结
- 在用户程序里面,有两个函数可以调用,一个是 signal,一个是 sigaction,推荐使用 sigaction。
- 用户程序调用的是 Glibc 里面的函数,signal 调用的是 __sysv_signal,里面默认设置了一些参数,使得 signal 的功能受到了限制,sigaction 调用的是 __sigaction,参数用户可以任意设定。
- 无论是 __sysv_signal 还是 __sigaction,调用的都是统一的一个系统调用 rt_sigaction。
- 在内核中,rt_sigaction 调用的是 do_sigaction 设置信号处理函数。在每一个进程的 task_struct 里面,都有一个 sighand 指向 struct sighand_struct,里面是一个数组,下标是信号,里面的内容是信号处理函数。

信号发送处理
有时候,我们在终端输入某些组合键的时候,会给进程发送信号,例如,Ctrl+C 产生 SIGINT 信号,Ctrl+Z 产生 SIGTSTP 信号。
硬件异常也会产生信号。比如,执行了除以 0 的指令,CPU 就会产生异常,然后把 SIGFPE 信号发送给进程。再如,进程访问了非法内存,内存管理模块就会产生异常,然后把信号 SIGSEGV 发送给进程。
注意,同样是硬件产生的,中断和信号的区别:中断要注册中断处理函数,但是中断处理函数是在内核驱动里面的,信号也要注册信号处理函数,但是在用户态进程里面。
对于硬件触发的,无论是中断还是信号,肯定是先到内核的,然后内核对于中断和信号处理方式不同。一个是完全在内核里面处理完毕,一个是将信号放在对应的进程 task_struct 里信号相关的数据结构里面,然后等待进程在用户态去处理。当然有些严重的信号,内核会把进程干掉。中断和信号的严重程度不一样,信号影响的往往是某一个进程,处理慢了或最多也不过这个进程被干掉,而中断影响的是整个系统,一旦中断处理中有了 bug,可能整个 Linux 都挂了。
内核在某些情况下,也会给进程发送信号。例如,向读端已关闭的管道写数据时产生 SIGPIPE 信号,当子进程退出时,我们要给父进程发送 SIG_CHLD 信号等。
最直接的发送信号的方法就是,通过命令 kill 来发送信号了。例如,我们都知道的 kill -9 pid 可以发送信号给一个进程,杀死它。
另外,我们还可以通过 kill 或者 sigqueue 系统调用,发送信号给某个进程,也可以通过 tkill 或者 tgkill 发送信号给某个线程。虽然方式多种多样,但是最终都是调用了 do_send_sig_info 函数,将信号放在相应的 task_struct 的信号数据结构中。
各个命令调用链路如下:
- kill->kill_something_info->kill_pid_info->group_send_sig_info->do_send_sig_info
- tkill->do_tkill->do_send_specific->do_send_sig_info
- tgkill->do_tkill->do_send_specific->do_send_sig_info
- rt_sigqueueinfo->do_rt_sigqueueinfo->kill_proc_info->kill_pid_info->group_send_sig_info->do_send_sig_info
do_send_sig_info 会调用 send_signal,进而调用 __send_signal。
进程数据结构中 task_struct 里面的 sigpending。上面的代码先要决定应该用哪个 sigpending。取决于信号是给进程的还是线程的。如果是 kill 发送的,也就是发送给整个进程的,就应该发送给 t->signal->shared_pending。这里面是整个进程所有线程共享的信号。如果是 tkill 发送的,也就是发给某个线程的,应该发给 t->pending。这里面是这个线程的 task_struct 独享的。
struct sigpending 里面有两个成员,一个是集合 sigset_t,表示收到了哪些信号,另一个链表,也表示收到了哪些信号。结构如下:
| |
两者的区别 (细节较多,暂略)
信号的发送与处理流程
1、假设有一个进程 A,main 函数里面调用系统调用进入内核。
2、按照系统调用的原理,会将用户态栈的信息保存在 pt_regs 里面,也即记住原来用户态是运行到了 line A 的地方。
3、在内核中执行系统调用读取数据。
4、当发现没有什么数据可读取的时候,进入睡眠状态,并且调用 schedule 让出 CPU,这是进程调度第一定律。
5、将进程状态设置为 TASK_INTERRUPTIBLE,可中断的睡眠状态,也即如果有信号来的话,是可以唤醒它的。
6、其他的进程或者 shell 发送一个信号,有四个函数可以调用 kill、tkill、tgkill、rt_sigqueueinfo。
7、四个发送信号的函数,在内核中最终都是调用 do_send_sig_info。
8、do_send_sig_info 调用 send_signal 给进程 A 发送一个信号,其实就是找到进程 A 的 task_struct,或者加入信号集合,为不可靠信号,或者加入信号链表,为可靠信号。
9、do_send_sig_info 调用 signal_wake_up 唤醒进程 A。
10、进程 A 重新进入运行状态 TASK_RUNNING,根据进程调度第一定律,一定会接着 schedule 运行。
11、进程 A 被唤醒后,检查是否有信号到来,如果没有,重新循环到一开始,尝试再次读取数据,如果还是没有数据,再次进入 TASK_INTERRUPTIBLE,即可中断的睡眠状态。
12、当发现有信号到来的时候,就返回当前正在执行的系统调用,并返回一个错误表示系统调用被中断了。
13、系统调用返回的时候,会调用 exit_to_usermode_loop。这是一个处理信号的时机。
14、调用 do_signal 开始处理信号。
15、根据信号,得到信号处理函数 sa_handler,然后修改 pt_regs 中的用户态栈的信息,让 pt_regs 指向 sa_handler。同时修改用户态的栈,插入一个栈帧 sa_restorer,里面保存了原来的指向 line A 的 pt_regs,并且设置让 sa_handler 运行完毕后,跳到 sa_restorer 运行。
16、返回用户态,由于 pt_regs 已经设置为 sa_handler,则返回用户态执行 sa_handler。
17、sa_handler 执行完毕后,信号处理函数就执行完了,接着根据第 15 步对于用户态栈帧的修改,会跳到 sa_restorer 运行。
18、sa_restorer 会调用系统调用 rt_sigreturn 再次进入内核。
19、在内核中,rt_sigreturn 恢复原来的 pt_regs,重新指向 line A。
20、从 rt_sigreturn 返回用户态,还是调用 exit_to_usermode_loop。
21、这次因为 pt_regs 已经指向 line A 了,于是就到了进程 A 中,接着系统调用之后运行,当然这个系统调用返回的是它被中断了,没有执行完的错误。
套接字(socket)
Reference
2.5 - 文件系统
Introduction
文件系统相关
2.5.1 - Linux文件系统-01设备
Linux文件系统
简介
Linux有多种文件系统,不同的文件系统支持不同的体系。文件系统是管理数据的,而存储数据的物理设备有硬盘、 U盘、 SD卡、 NAND FLASH、 NOR FLASH、网络存储设备等。不同的存储设备有不同的物理结构,因此就需要不同的文件系统去管理,比如管理 NAND FLASH 使用YAFFS 文件系统,管理硬盘/SD卡使用ext文件系统等。
分类
Linux支持很多文件系统格式,常见文件系统类型:
ext2 : 早期linux中常用的文件系统
ext3 : ext2的升级版,带日志功能
RAMFS : 内存文件系统,速度很快
NFS : 网络文件系统,由SUN发明,主要用于远程文件共享
MS-DOS : MS-DOS文件系统
VFAT : Windows 95/98 操作系统采用的文件系统
FAT : Windows XP 操作系统采用的文件系统
NTFS: Windows NT/XP 操作系统采用的文件系统
HPFS : OS/2 操作系统采用的文件系统
PROC : 虚拟的进程文件系统
ISO9660 : 大部分光盘所采用的文件系统
ufsSun : OS 所采用的文件系统
NCPFS : Novell 服务器所采用的文件系统
SMBFS : Samba 的共享文件系统
XFS : 由SGI开发的先进的日志文件系统,支持超大容量文件
JFS :IBM的AIX使用的日志文件系统
ReiserFS : 基于平衡树结构的文件系统
udf: 可擦写的数据光盘文件系统
大体可分以下几类:
磁盘文件系统
指本地主机中实际可以访问到的文件系统,包括硬盘、CD-ROM、DVD、USB存储器、磁盘阵列等。常见格式有:Ext2、Ext3、Ext4、JFS、NTFS、UFS、FAT、FAT16、FAT32等
网络文件系统
是可以远程访问的文件系统,在服务器端仍是本地磁盘文件系统,客户机通过网络远程访问数据。 常见格式有:NFS、Samba等
专有/虚拟文件系统
不驻留在磁盘上的文件系统。常见格式有:TMPFS、PROCFS等。
磁盘文件系统
目前Ext4(Extended File sytem,扩展文件系统)是广泛使用的一种磁盘文件系统格式。是在Ext3基础上发展起来的,对有效性保护、数据完整性、数据访问速度、向下兼容性等方面做了改进,其特点是日志文件系统:可将整个磁盘的写入动作完整地记录在磁盘的某个区域上,以便在必要时回溯追踪。
磁盘是一种计算机的外部存储器设备,由一个或多个覆盖有磁性材料的铝制或玻璃制的碟片组成,用来存储用户的信息,这种信息可以反复地被读取和改写。磁盘主要分为一下几类:
IDE磁盘
Integrated Drive Electronics,价格低廉,兼容性强,性价比高,但是数据传输慢,不支持热插拔等。
SCSI磁盘
Small Computer System Interface,传输速率高,读写性能好,运行稳定,可连接多个设备,支持热插拔,占用CPU低,但是价格相对较贵,一般用于工作站或服务器上。
SATA磁盘
Serial Advanced Technology Attachment,结构简单、支持热插拔。
磁盘分区
为了便于管理和使用,通常会对磁盘进行分区。

主分区
必须要存在的分区,最多能创建4个,最少1个,编号只能是1 - 4 (比如sda1、sda2、sda3、sda4),可以直接格式化,然后安装系统直接存放文件。
扩展分区
会占用主分区位置,即主分区+扩展分区之和最多4个。相当于独立的磁盘,有独立的分区表,但不能独立的存放数据
逻辑分区
扩展分区不能直接存放数据,必须经过再次分割成为逻辑分区后才能存放数据。一个扩展分区中的逻辑分区可以有任意多个,编号只能从5开始。
交换分区
安装系统时建立的,一块特殊的硬盘空间,当实际内存不够用时,操作系统会从内存中取出部分暂时不用的数据,放在swap中,为当前程序腾出足够的内存空间。swap不会使用到目录树的挂载,无需指定挂载点(即cd无法进入)。
设备
设备接入系统后都是以文件的形式存在
设备文件名称
| 设备类型 | 文件名称 |
|---|---|
| SATA/SAS/USB | /dev/sda,/dev/sdb (s= SATA, d=DISK a=第几块) |
| IDE | /dev/hd0,/dev/hd1 (h= hard) |
| VIRTIO-BLOCK | /dev/vda,/dev/vdb (v=virtio)虚拟io输出 |
| M2(SSD) | /dev/nvme0,/dev/nvme1(nvme=m2) |
| SD/MMC/EMMC(卡) | /dev/mmcblk0,/dev/mmcblk1(mmcblk=mmc卡 ) |
| 光驱 | /cdrom,/dev/sr0,/dev/sr1 |
设备命令规则
| |
举例:
| |
文件系统结构
除了swap分区外,其他主分区、扩展分区、逻辑分区都是在根分区(/)目录上操作的。Linux文件系统是一个树形的分层组织结构,根作为整个文件系统的唯一起点,其他所有目录都从该点出发。根分区下的一级目录有:
- bin
- boot
- dev
- etc
- home
- lib
- mnt
- opt
- proc
- root
- sbin
- srv
- sys
- tmp
- usr
- var
操作命令
fdisk
磁盘分区表操作工具。命令格式:
| |
常用选项:
| |
file
file命令用于辨识文件类型。命令的格式为:
| |
常用选项:
| |
ln
link files的缩写,是为某一个文件在另外一个位置建立一个同步的链接。
命令格式:
| |
常用选项:
| |
mount
挂载本地设备
对分区/dev/sda3的操作命令:
1、挂载:mount /dev/sda1 /mnt/asd,这样挂载分区到文件系统上,才能看/mnt/asd里的东西。
2、查看:ls -hl /mnt/asd
3、卸载:umount /dev/sda1
文件系统:指定要卸载的文件系统或者其对应的设备文件名
(1)、通过设备名卸载:umount -v /dev/sda1
(2)、通过挂载点卸载:umount -v /mnt/mymount/
挂载网络设备
挂载 NFS 服务作为网络磁盘:
| |
挂载后,可以在 /mnt/nfs 目录下查看 NFS 服务器上的文件。
要在系统启动时自动挂载 NFS,可以将以上命令添加到 /etc/fstab 文件中,例如:
| |
注意:在生产环境中,使用前需要调整访问安全性的设置,并确保挂载的代码的正确性。
设备查看
| 命令 | 作用 |
|---|---|
| fdisk -l | 查看磁盘分区情况 |
| lsblk | 设备使用情况 |
| blkid | 设备管理方式及设备ID |
| df | 查看正在被系统挂载的设备 |
| cat /proc/partitions | 查看系统识别设备 |
Reference
https://docs.aws.amazon.com/zh_cn/AWSEC2/latest/UserGuide/device_naming.html
3 - NetWork
Introduction
计算机网络
3.1 - 端口转发socat
简介
socat 功能跟 NetCat 一样,但更安全(支持 chroot ),兼容多种协议, 支持操作 文件 ( file )、 管道 ( pipe )、 设备 ( device )、 TCP 套接字、 Unix 套接字、 SOCKS 客户端、 CONNECT 代理以及 SSL 等等。
安装
| |
端口转发
| |
| |
实际使用
| |
运行:bash socat_hb.sh 10.231.243.5
| |
ssh端口转发
ssh基本操作
Expand/Collapse Code Block
| |
ssh正向转发
将当前的请求转发到远程服务器
| |
ssh反向转发
将远程服务器的请求转发到本机
| |
3.2 - 认证鉴权
Introduction
认证鉴权
3.2.1 - OAuth2介绍
四种方式
授权码
**授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。**授权码只能使用一次
获取UserToken时序图

举例百度获取微信认证

步骤:
1、A 网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。
2、用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码。
3、A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。
4、B 网站收到请求以后,就会颁发令牌。具体做法是向redirect_uri指定的网址,发送一段 JSON 数据。
隐藏式
1、A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。
2、用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回redirect_uri参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站
这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了
密码式
高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"(password)
凭证式
凭证式(client credentials),适用于没有前端的命令行应用,即在命令行下请求令牌。
这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。
Reference
3.3 - protocol
Introduction
协议
3.3.1 - HTTP协议介绍
HTTP/1.0
无状态无连接的应用层协议
- 无状态:服务不跟踪不记录请求过的状态
- 无连接:浏览器每次请求都需要建立 TCP 连接 HTTP/1.0 规定浏览器和服务器保持短暂的连接。浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开 TCP 连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。无状态导致的问题可以借助 cookie/session 机制来做身份认证和状态记录解决。
然而,无连接特性将会导致以下性能缺陷:
- 无法复用连接。每次发送请求都需要进行一次 TCP 连接,而 TCP 连接和释放都比较麻烦,会导致网络的利用率非常低。
- 队头堵塞(head of line blocking)。由于 HTTP/1.0 规定下一个请求必须在前一个请求响应到达之前才能发送。假设一个请求响应一直不到达,那么下一个请求就不发送,就到导致阻塞后面的请求。 为了解决这些问题,HTTP/1.1出现了。
HTTP/1.1
HTTP/1.1 的改进:
长连接
增加了一个 Connection 字段,设置为 keep-alive(默认值)可以保持连接不断开,避免每次请求都要重新建立 TCP 连接。客户端关闭连接可通过设置 Connection: fase 来告诉服务器关闭请求。
请求管道化
支持请求管道化,即 pipelining
基于 HTTP/1.1 的长连接,使得请求管线化成为可能。管线化使得请求能够“并行”传输。举个例子来说,假如响应的主体是一个html页面,页面中包含了很多图片,这时 keep-alive 能够进行“并行”发送多个请求。
注意:这里的“并行”并不是真正的并行。服务器必须按照客户端请求的先后顺序依次回送相应的结果,以保证客户端能够区分出每次请求的响应内容。
可见,HTTP/1.1还是无法解决队头阻塞(head of line blocking)的问题。同时“管道化”技术存在各种各样的问题,所以很多浏览器要么根本不支持它,要么就直接默认关闭,并且开启的条件很苛刻,实际上好像并没有什么用处。
补充:在 Chrome 等浏览器控制台看到的并行请求是通过允许打开多个 TCP 会话来实现的真正的并行。
缓存处理
断点传输
cache-control 字段
Host
一个服务器能够利用 Host 字段用来创建多个Web站点
SPDY
背景
虽然 HTTP1.0 和 HTTP 1.1 存在这么多问题,业界也是想出了各种优化手段,但都是治标不治本,直到 SPDY 的诞生。
SPDY 是由 Google 在2020年提出的方案,改进版本的HTTP1.1 (那时候还没有HTTP2)。它基于TCP协议,在HTTP的基础上,结合HTTP1.X的多个痛点进行改进和升级的产物。它的出现让大家开始从正面看待和解决老版本 HTTP 协议本身的问题,使web的加载速度有极大的提高。HTTP2也借鉴了很多 SPDY 的特性。
目标
SPDY 的目标在于解决 HTTP 的缺陷,即延迟和安全性。我们上面一直在讨论延迟,至于安全性,虽然我们上面没有具体聊,不过 HTTP 的明文传输确是个问题。如果以降低延迟为目标,应用层的 HTTP 和传输层的 TCP 都是都有调整的空间,不过 TCP 作为更底层协议存在已达数十年之久,其实现已深植全球的网络基础设施当中,如果要动必然伤经动骨,业界响应度必然不高,所以 SPDY 的手术刀对准的是 HTTP 。
- 降低延迟,客户端的单连接单请求,服务端的 FIFO 响应队列都是延迟的大头。
- HTTP 最初设计都是客户端发起请求,然后服务端进行响应,服务端无法主动发送内容到客户端。
- 压缩 HTTP header,HTTP 1.x 的 header 越来越膨胀,cookie 和 user agent 很容易让 header 的 size 增至1kb 大小甚至更多。而且由于 HTTP 的无状态特性,header 必须每次请求都重复携带,很浪费流量。 为了增加解决这些问题的可行性,Google 一开始就避开了从传输层动手,而且打算利用开源社区的力量以提高扩散的力度,对于协议使用者来说,也只需要在请求的 header 里设置 user agent,然后在服务端做好支持即可,极大的降低了部署的难度。SPDY 的设计如下:

SPDY位于HTTP之下,TCP和SSL之上,这样可以轻松兼容老版本的HTTP协议(将http1.x的内容封装成一种新的frame格式),同时可以使用已有的SSL功能。SPDY的功能可以分为基础功能和高级功能两部分,基础功能默认启用,高级功能需要手动启用。
基础功能
多路复用(multiplexing) 多路复用通过多个请求共用一个连接的方式,降低了 TCP 连接建立和释放的开销,同时提高了带宽的利用率。
请求优先级(request prioritization) 多路复用带来的一个问题是,在共享连接的基础上会存在一些关键请求被阻塞,SPDY 允许给每个请求设置优先级,这样重要的请求就会优先得到响应。
header 压缩 前面提到的 HTTP 1.x 的 header 很多时候都是重复而且多余的。选择合适的压缩算法可以减小包的大小和数量。SPDY 对 header 的压缩素可以达到 80% 以上。
高级功能
服务端推送 HTTP 只能由客户端发送,服务器只能被动发送响应。不过在开启服务端推送后,服务端通过 **X-Associated-Content ** header 会告知服务器会有新的内容被推送过来,
服务端暗示 和服务端推送所不同的是,服务端暗示不会推送内容,只是告诉客户端有新的内容产生,,内容的下载还是需要客户端主动发起请求。服务端暗示通过 X-Subresources header 来通知,一般应用场景是客户端需要先查询服务端状态,然后再下载资源,可以节约一次查询请求。
自从 SPDY 出现后,页面加载时间相比于 HTTP 减少了 64%,而且各大浏览器厂商在 SPDY 诞生之后的 1 年多时间里也都陆续支持了 SPDY。但是,SPDY 的生存时间却没有人们想象中的那么长,SPDY 从 2012 年诞生到 2016 年停止维护。
HTTP/2.0
基本概念
- 帧
HTTP/2.0 数据通信的最小单位消息:指 HTTP/2.0 中逻辑上的 HTTP 消息。例如请求和响应等,消息由一个或多个帧组成。
- 流
连接中的一个虚拟通道。流可以承载双向消息,每个流都有一个唯一的整数ID。
- 消息
与逻辑消息对应的完整的一系列数据帧。
兼容HTTP/1.1
二进制分帧
http1.x诞生的时候是明文协议,其格式由三部分组成:start line(request line或者status line),header,body。要识别这3部分就要做协议解析,http1.x的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑http2.0的协议解析决定采用二进制格式,实现方便且健壮。
HTTP/2.0 采用二进制格式传输数据,而非 HTTP/1.x 的文本格式,二进制协议解析起来更高效。 HTTP/1.x 的请求和响应报文,都是由起始行,首部和实体正文(可选)组成,各部分之间以文本换行符分隔。HTTP/2.0 将请求和响应数据分割为更小的帧,并且它们采用二进制编码。
http2.0用binary格式定义了一个一个的frame,和http1.x的格式对比如下图:

http2.0的格式定义更接近tcp层的方式,这张二机制的方式十分高效且精简。length定义了整个frame的开始到结束,type定义frame的类型(一共10种),flags用bit位定义一些重要的参数,stream id用作流控制,剩下的payload就是request的正文了。
虽然看上去协议的格式和http1.x完全不同了,实际上http2.0并没有改变http1.x的语义,只是把原来http1.x的header和body部分用frame重新封装了一层而已。调试的时候浏览器甚至会把http2.0的frame自动还原成http1.x的格式。具体的协议关系可以用下图表示:

多路复用
- 同域名下的所有通信都在单个连接中完成。
- 单个连接可以承载任意数量的双向数据流。
- 数据流以消息的形式发送,而消息又由一个或多个帧组成,多个帧之间可以乱序发送,因为根据帧首部的流标识可以重新组装。性能有很大提升。
- 同个域名只需要占用一个 TCP 连接,消除了因多个 TCP 连接而带来的延时和内存消耗。
- 单个连接上可以并行交错地请求和响应,之间互不干扰。
- 每个请求都可以带一个31bit的优先值(0表示最高优先级,数值越大优先级越低)。客户端和服务器可以在处理不同流时采取不同策略,以最优的方式发送流、消息和帧。 在 HTTP/1.1 协议中,浏览器客户端在同一时间、针对同一域名下的请求有一定数量限制,超过限制数据的请求会被阻塞。
而 HTTP/2.0 实现了真正的并行传输,能够在一个 TCP 上进行任意数量 HTTP 请求。而这个强大的功能则是基于“二进制分帧”的特性。
多路复用对延迟的改变可以参考:https://http2.akamai.com/demo
服务器推送
服务端可以主动推送,在发送页面 HTML 时主动推送其它资源,而不用等到浏览器解析到相应位置,发起请求再响应。
客户端也有权选择是否接收。如果服务端推送的资源已经被浏览器缓存过,浏览器可以通过发送 RST_STREAM 帧来拒收。主动推送也遵守同源策略,服务器不会随便推送第三方资源给客户端。
头部压缩
在 HTTP/1.x 中,头部元数据都是以纯文本的形式发送的,通常会给每个请求增加 500~800 字节的负荷。
HTTP/2.0 使用 encoder 来减少需要传输的 header 大小,通讯双方各自 cache 一份 header fields 表,既避免了重复 header 的传输,又减小了需要传输的大小。高效的压缩算法可以很大地压缩 header,减少发送包的数量从而降低延迟。
QUIC
简介
QUIC 是一种建立在 UDP 之上的新型多路复用传输。HTTP/3 旨在利用 QUIC 的功能,包括缺少流之间的 Head-Of-Line 阻塞。
特性
基于UDP建立的连接
基于TCP的协议,如http2,在首次建立连接的时候需要进行三次握手,即至少需要3个ntt,而考虑安全HTTPS的TLS层,又需要至少次的通信才能协商出密钥。这在短连接的场景中极大的增加了网络延迟,而这种延迟是无法避免的。
而基于UDP的quic协议,则不需要3次握手的过程,甚至在安全协商阶段只需要进行1~2次的协商通信,即可建立安全稳定的连接,极大的减少了网络延迟。
基于Diffie-Hellman的加密算法
HTTPS 使用的是 TLS + SSL 的加密手段,在交换证书、协商密钥的过程中,至少需要2次ntt进行协商通信。而quic使用了Diffie-Hellman算法,算法的原理使得客户端和浏览器之间只需要1次的协商就能获得通信密钥,quic建立安全链接的详细过程:

- 客户端发起Inchoate client hello
- 服务器返回Rejection,包括密钥交换算法的公钥信息,算法信息,证书信息等被放到server config中传给客户端
- 客户端发起client hello,包括客户端公钥信息 后续发起连接的过程中,一旦客户端缓存或持久化了server config,就可以复用并结合本地生成的私钥进行加密数据传输了,不需要再次握手,从而实现0RTT建立连接。
连接的迁移
在以往的基于TCP的协议中,往往使用四元组(源IP,源端口,目的IP,目的端口)来标识一条连接,当四元组中的IP或端口任一个发生变化了连接就需要重新建立,从而不具备连接迁移的能力。
而QUIC使用了connection id对连接进行唯一标识。即使网络从4G变成了wifi,只要两次连接中的 connection id不变,并且客户端或者服务器能通过校验,就不需要重新建立连接,连接迁移就能成功。
这在移动端场景的优势极为明显,因为手机经常会在wifi和4g中切换,使用quic协议降低了重建连接的成本。
协商的升级
在chorme浏览器中,发起一个TCP请求,这个请求会同时与服务器开始建立tcp 和 quic 的连接(前提是服务器支持),如果quic连接先建立成功,则使用quic建立的连接通信,反之,则使用tcp建立的连接进行通信。具体步骤如下:
- 客户端发出tcp请求
- 服务端如果支持quic可以通过响应头alt-svc告知客户端
- 客户端同时发起tcp连接和quic连接竞赛
- 一旦quic建立连接获胜则采用quic协议发送请求
- 如遇网络或服务器不支持quic/udp,客户端标记quic为broken
- 传输中的请求通过tcp重发
- 5min后尝试重试quic,下一次尝试增大到10min
- 一旦再次成功采用quic并把broken标记取消
其他特性
- 改进的拥塞控制
- 丢包恢复
- 底层的连接持久化
- head stream 保证包顺序
- 双级别流量控制
HTTP3
背景
HTTP2协议虽然大幅提升了HTTP/1.1的性能,然而,基于TCP实现的HTTP2遗留下3个问题:
有序字节流引出的队头阻塞(Head-of-line blocking),使得HTTP2的多路复用能力大打折扣;TCP与TLS叠加了握手时延,建链时长还有1倍的下降空间;基于TCP四元组确定一个连接,这种诞生于有线网络的设计,并不适合移动状态下的无线网络,这意味着IP地址的频繁变动会导致TCP连接、TLS会话反复握手,成本高昂。
解决的问题
HTTP3基于UDP协议重新定义了连接,在QUIC层实现了无序、并发字节流的传输,解决了队头阻塞问题(包括基于QPACK解决了动态表的队头阻塞);HTTP3重新定义了TLS协议加密QUIC头部的方式,既提高了网络攻击成本,又降低了建立连接的速度(仅需1个RTT就可以同时完成建链与密钥协商);HTTP3 将Packet、QUIC Frame、HTTP3 Frame分离,实现了连接迁移功能,降低了5G环境下高速移动设备的连接维护成本。
基于TCP协议的好处是可以把网络数据的完整性由传输层保证,应用层只用关心传输什么内容,如何利用这些内容实现应用功能。 HTTP/3是基于UDP协议的,传输数据的完全性由应用层来保证。
各协议区别
keep-alive与多路复用区别
- HTTP/1.x 是基于文本的,只能整体去传;HTTP/2 是基于二进制流的,可以分解为独立的帧,交错发送
- HTTP/1.x keep-alive 必须按照请求发送顺序返回响应;HTTP/2 多路复用不按序响应
- HTTP/1.x keep-alive 为了解决队头阻塞,将同一个页面的资源分散到不同域名下,开启了多个 TCP 连接;HTTP/2 同域名下所有通信都在单个连接上完成
- HTTP/1.x keep-alive 单个 TCP 连接在同一时刻只能处理一个请求(两个请求的生命周期不能重叠);HTTP/2 单个 TCP 同一时刻可以发送多个请求和响应
pipelining与多路复用
HTTP/1.1 版本的管线化(pipelining)理论,默认关闭。与 HTTP/2.0 的多路复用比较类似,具体是什么区别?
HTTP/1.1 的管线化只能串行,即一个相应必须完全返回后,下一个请求才会开始传输。
HTTP/2.0 的多路复用则是利用分帧数据流,把HTTP协议分解为胡不依赖的帧(为每个帧标序发送,接收回来的时候按序重组),进而可以乱序发送避免一定程度上的队首阻塞问题。
但是无论是 HTTP/1.1 还是 HTTP/2.0,response响应的处理顺序总是需要跟request的请求顺序保持一致的。假如某个请求的response响应较慢,还是同样会有阻塞的问题。主要是受限于HTTP底层的传输协议是TCP,没办法完全解决线头阻塞的问题。
Reference
3.3.2 - HTTPS加密原理
背景
HTTP的内容是明文传输的,明文数据会经过中间代理服务器、路由器、wifi热点、通信服务运营商等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了。劫持者还可以篡改传输的信息且不被双方察觉,这就是中间人攻击。所以需要对信息进行加密。最容易理解的就是对称加密。
对称加密
定义
一个密钥可以加密一段信息,也可以对加密后的信息进行解密。
可行性
如果通信双方都各自持有同一个密钥,且没有别人知道,这两方的通信安全当然是可以被保证的(除非密钥被破解)。
但是最大的问题就是这个密钥怎么让传输的双方知晓,同时不被别人知道。如果由服务器生成一个密钥并传输给浏览器,那在这个传输过程中密钥被别人劫持到手了怎么办?之后它就能用密钥解开双方传输的任何内容,所以这么做当然不行。
非对称加密
简单说就是有两把密钥,通常一把叫做公钥、一把叫私钥,用公钥加密的内容必须用私钥才能解开,同样私钥加密的内容只有公钥能解开。
可行性
服务端到浏览器链路的安全
鉴于非对称加密的机制,简单的思路:服务器先把公钥以明文方式传输给浏览器,之后浏览器向服务器传数据前都先用这个公钥加密好再传,这条数据的安全似乎可以保障了!因为只有服务器有相应的私钥能解开公钥加密的数据。
然而反过来由服务器到浏览器的这条路怎么保障安全?即服务器怎么将公钥安全地传给浏览器不被劫持。如果服务器用它的私钥加密数据传给浏览器,那么浏览器用公钥可以解密它,而这个公钥是一开始通过明文传输给浏览器的,若这个公钥被中间人劫持到了,那它也能用该公钥解密服务器传来的信息了。所以目前似乎只能保证由浏览器向服务器传输数据的安全性。
中间人攻击
- 某网站有用于非对称加密的公钥A、私钥A’。
- 浏览器向网站服务器请求,服务器把公钥A明文给传输浏览器。
- 中间人劫持到公钥A,保存下来,把数据包中的公钥A替换成自己伪造的公钥B(它当然也拥有公钥B对应的私钥B’)。
- 浏览器生成一个用于对称加密的密钥X,用公钥B(浏览器无法得知公钥被替换了)加密后传给服务器。
- 中间人劫持后用私钥B’解密得到密钥X,再用公钥A加密后传给服务器。
- 服务器拿到后用私钥A’解密得到密钥X。
数字证书
简介
为了解决浏览器收到的公钥一定是该网站的公钥,CA机构给网站颁发的“身份证”即数字证书用来证明网站身份。
网站在使用HTTPS前,需要向CA机构申领一份数字证书,数字证书里含有证书持有者信息、公钥信息等。服务器把证书传输给浏览器,浏览器从证书里获取公钥就行了,证书就如身份证,证明“该公钥对应该网站”。
防伪
这里又有一个显而易见的问题,“证书本身的传输过程中,如何防止被篡改”?即如何证明证书本身的真实性?身份证运用了一些防伪技术,而数字证书怎么防伪呢?
把证书原本的内容生成一份“签名”,比对证书内容和签名是否一致就能判别是否被篡改。这就是数字证书的“防伪技术”,这里的“签名”就叫数字签名。
数字签名
数字签名的制作过程:
- CA机构拥有非对称加密的私钥和公钥。
- CA机构对证书明文数据T进行hash。
- 对hash后的值用私钥加密,得到数字签名S。 明文和数字签名共同组成了数字证书,这样一份数字证书就可以颁发给网站了。
那浏览器拿到服务器传来的数字证书后,如何验证它是不是真的?(有没有被篡改、掉包)
浏览器验证过程:
- 拿到证书,得到明文T,签名S。
- 用CA机构的公钥对S解密(由于是浏览器信任的机构,所以浏览器保有它的公钥。详情见下文),得到S’。
- 用证书里指明的hash算法对明文T进行hash得到T’。显然通过以上步骤,T’应当等于S‘,除非明文或签名被篡改。所以此时比较S’是否等于T’,等于则表明证书可信。 为什么能保证证书可信?
- 没有私钥无法篡改签名
- 无法掉包证书,因为证书里有域名,不一致则无法掉包
HTTPS验证流程
1、客户端发起一个http请求,告诉服务器自己支持哪些hash算法。
2、服务端把自己的信息以数字证书的形式返回给客户端(证书内容有密钥公钥,网站地址,证书颁发机构,失效日期等)。证书中有一个公钥来加密信息,私钥由服务器持有。
3、验证证书的合法性
客户端收到服务器的响应后会先验证证书的合法性(证书中包含的地址与正在访问的地址是否一致,证书是否过期)。
4、生成随机密码(RSA签名)
如果验证通过,或用户接受了不受信任的证书,浏览器就会生成一个随机的对称密钥(session key)并用公钥加密,让服务端用私钥解密,解密后就用这个对称密钥进行传输了,并且能够说明服务端确实是私钥的持有者。
5、生成对称加密算法
验证完服务端身份后,客户端生成一个对称加密的算法和对应密钥,以公钥加密之后发送给服务端。此时被黑客截获也没用,因为只有服务端的私钥才可以对其进行解密。之后客户端与服务端可以用这个对称加密算法来加密和解密通信内容了。
Reference
4 - IO
Introduction
I/O知识介绍
4.1 - Java NIO核心知识
简介
NIO(Non-blocking I/O,在Java领域,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础。IO多路复用,一言以蔽之,就是**"复用"一个线程或一个进程,同时监测若干个("多路")文件描述符是否可以执行IO操作的能力。** 在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。**I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。**与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统维护的工作量,节省了系统资源。
IO多路复用的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。
关于 Java NIO 相关的核心,总的来看包含以下三点,分别是:
- Channel
- Buffer
- Selector
BIO
通过 socket 通信,实际上就是通过文件描述符 fd 读写文件
C10k问题
C10K 就是 Client 10000 问题,即「在同时连接到服务器的客户端数量超过 10000 个的环境中,即便硬件性能足够, 依然无法正常提供服务」,简而言之,就是单机1万个并发连接问题。这个概念最早由 Dan Kegel 提出并发布于其个人站点( http://www.kegel.com/c10k.html )
设计 Unix 的 PID 的时候,采用了有符号的16位整数,这就导致一台计算机上能够创建出来的进程无法超过32767个。
Channel
Channel 就是通道。可以往通道里写数据、读数据。它是双向的,与之配套的是 Buffer,即要往一个通道里写数据,必须要将数据写到一个 Buffer 中,然后写到通道里。同样,从通道里读数据,必须将通道的数据先读取到一个 Buffer 中,然后再操作。
在 NIO 中 Channel 有多种类型:
- SocketChannel
- ServerSocketChannel
- DatagramChannel
- FileChannel
SocketChannel
对标 Socket,可以直接将它看做所建立的连接。通过 SocketChannel ,可以利用 TCP 协议进行读写网络数据。
SocketChannel 主要在两个地方出现:
- 客户端,客户端创建一个 SocketChannel 用于连接至远程的服务端。
- 服务端,服务端利用 ServerSocketChannel 接收新连接之后,为其创建一个 SocketChannel 。 随后,客户端和服务端就可以通过这两个 SocketChannel 相互发送和接收数据。
ServerSocketChannel
服务端创建的 Socket,可以对标 ServerSocket。
主要是用来接待新连接,监听新建连的 TCP 连接,为新进一个连接创建对应的 SocketChannel。然后通过新建的 SocketChannel 可以进行网络数据的读写,与对端交互。
ServerSocketChannel 主要出现在一个地方:服务端。
服务端需要绑定一个端口,然后监听新连接的到来,这个活儿就由 ServerSocketChannel 来干。
服务端内常常会利用一个线程,一个死循环,不断地接收新连接的到来。
| |
DatagramChannel
Datagram(数据报),即 UDP 协议,是无连接协议。利用 DatagramChannel 可以直接通过 UDP 进行网络数据的读写。
FileChannel
文件通道,用来进行文件的数据读写。
我们日常开发主要是基于 TCP 协议,所以我们把精力放在 SocketChannel 和 ServerSocketChannel 上即可。
Buffer
Buffer 就是内存中可以读写的一块地方,叫缓冲区,用于缓存数据。
其并没有太多原理可介绍,主要关注 Java NIO Buffer 的 API 即可。当然其 API 有很多优化之处,所以 Netty 没用 Java NIO Buffer 而是自行实现了一个 Buffer,叫 ByteBuf。
为什么 Channel 必须和 Buffer 搭配使用?
其实网络数据是面向字节的,但是读写的数据往往是多字节的,假设不用 Buffer 那需要一个字节一个字节的调用读和调用写,非常麻烦。 所以搞个 Buffer 将数据拢一拢,这样之后的调用才能更好地处理完整的数据,方便异步的处理等等。
Selector
Selector 是 I/O 多路复用的核心组件。
一个 Selector 上可以注册多个 Channel ,从上面已经知道一个 Channel 就对应了一个连接,因此一个 Selector 可以管理多个 Channel 。
当任意 Channel 发生读写事件时,通过 Selector.select() 就可以捕捉到事件的发生,因此可以利用一个线程,死循环的调用 Selector.select(),这样可以利用一个线程管理多个连接,减少了线程数,减少了线程的上下文切换和节省了线程资源。这就是 Selector 的核心功能。
具体详细步骤:
创建一个 Selector
1Selector selector = Selector.open();将要被管理的 Channel 注册到 Selector 上,并声明感兴趣的事件
1SelectionKey key = channel.register(selector, Selectionkey.OP_READ | Selectionkey.OP_WRITE);
事件类型:
| |
当 Channel 发生读或写事件,调用
Selector.select()就可以得知有事件发生。 该函数具体有3个重载方法:- int selectNow():不论是否有误事件发生,立即返回
- int select(long timeout):至多阻塞 timeout 时间(或被唤醒),如果提前有事件发生则提前返回
- int select():一直阻塞,直到有事件发生(或被唤醒)。 返回值就是就绪的通道数,一般判断大于 0 即可后续的操作,即调用:
| |
- 获得一个类型为 Set 的 selectedKeys 集合。 可以通过 selectedKey 得知当前发生的是什么事件,有 isAcceptable、isReadable 等等。
还能获得对应 channel 进行相应的读写操作,还有获取 attachment 等等。
所以得到了 selectedKeys 就可以通过迭代器遍历所有发生事件的连接,然后进行操作:
Expand/Collapse Code Block
| |
还有个方法是 Selector.wakeup(),可以唤醒阻塞着的 Selector。
前提:如果 Channel 要和 Selector 搭配,那它必须得是非阻塞的,即配置:
| |
从上面的分析,可以得知 Selector 处理事件的时候必须快,如果长时间处理某个事件,那么注册到 Selector 上的其他连接的事件就不会被及时处理,造成客户端阻塞。
4.2 - Netty服务器启动源码分析
Tips
netty-xxx-sources.jar包中会有个example文件夹,里面有netty的使用案例。本文可以以 EchoServer为例
IDEA查看xx-sources.jar
sources.jar包可以通过 Moudels -> 添加 -> 右键菜单 -> Copy to Module Libraries -> Copy library files to -> 项目的lib中可以看到该jar包


可以将demo的代码拷贝到项目中运行debug源码
Demo
EchoServer Demo
Expand/Collapse Code Block
| |
普通Demo
| |
以下源码以 netty-4.1.1-Final.jar为例
NioEventLoopGroup
以下根据 EchoServer 的main 的入口源码进行分析
| |
MultithreadEventExecutorGroup
NioEventLoopGroup继承了该类,其初始化主要该类的初始化过程。
默认线程个数
通过对 NioEventLoopGroup() 的跟踪,发现如果未指定线程个数,会调用MultithreadEventLoopGroup构造函数,默认使用 DEFAULT_EVENT_LOOP_THREADS ,而该参数的初始化是:
| |
参数说明
该类的构造方法才是 NioEventLoopGroup 真正的构造方法,这里可以看做是一个模板方法(设计模式)。
| |
初始化过程
往下可以看到会创建 EventExecutor 数组 children
可以通过快捷键 ctrl + H(mac系统)查看类的 EventExecutor 类的层次结构,可以发现 EventExecutor <- SingleThreadEventLoop <- NioEventLoop,这里每个元素的类型是 NioEventLoop。
该函数中还对每个元素加了监听器
Expand/Collapse Code Block
| |
分析说明: 1、如果 executor 是null,创建一个默认的 ThreadPerTaskExecutor,使用Netty默认的线程工厂
2、根据传入的线程数(CPU*2)创建一个线程池的(单例线程池)数组
3、循环填充数组中元素。如果异常,则关闭所有的单例线程池
4、根据线程选择工程创建一个 线程选择器
5、为每一个单例线程池添加一个关闭监听器
6、将所有单例线程池添加到一个 HashSet 中
NioEventLoop
| |
父类SingleThreadEventExecutor
| |
该初始化函数主要做了两件事: 1、利用ThreadFactory创建来一个Thread,传入了一个Runnable对象,该Runnable重写的run代码比较长,不过重点仅仅是调用NioEventLoop类的run方法。
2、使用LinkedBlockingQueue类初始化taskQueue 。
绑定group
用于后期引导使用
| |
添加channel
其中参数一个Class对象,引导类将通过这个Class对象反射创建 ChannelFactory。
| |
channel的创建
| |
可以通过 debug 或 查看 initAndRegister 方法调用层次
日志处理器handler
添加服务器专属日志处理器handler。
注意是 ServerSocketChannel
| |
xxServerHandler
添加SocketChannel的handler
注意不是 ServerSocketChannel
| |
这是一个普通的处理器类,用于处理客户端发送来的消息,入口函数中在 ServerBootstrap 的 .chanldHanlder 中添加该hanlder。
这里处理主要分析客户端发送内容来解析、打印、响应发送字符串给客户端的过程。
| |
ServerBootstrap
try块中创建了一个 ServerBootstrap 对象,它是一个引导类,用于启动服务器和引导整个程序的初始化。它和 ServerChannel 关联,而 ServerChannel 继承了 Channel。
| |
类的继承关系
ServerBootstrap -> AbstractBootstrap --> Cloneable
空构造函数
但是有默认的成员变量,会直接初始化
| |
构造过程
| |
分析说明: 1、链式调用:group 方法,将boss和worker传入,boss赋值给parentGroup属性,worker赋值给childGroup属性
2、channel 方法传入 NioServerSocketChannel.class 对象。会根据这个创建 channel 对象
3、option 方法传入TCP参数,放在一个LinkedHashMap中
4、handler 方法传入一个hanlder,该hanler专属于 ServerSocketChannel 而不是 SocketChannel
5、childHandler 传入一个handler,这个handler将会在每个客户端连接的时候调用。供 SocketChannel 使用。
绑定端口
服务器在bind方法中启动完成的
| |
bind方法
| |
核心代码为doBind方法
validate方法
该方法主要检查了两个参数,一个是group,一个是channelFactory。
这两个将bossGroup赋值给了group,将BootstrapChannelFactory赋值给了channelFactory。赋值如下:
| |
doBind方法
核心是两个方法 initAndRegister 和 doBind0
Expand/Collapse Code Block
| |
doBind函数是分析重点,主要工作如下: 1、initAndRegister()方法得到一个ChannelFuture的实例regFuture。
2、regFuture.cause()方法判断是否在执行initAndRegister方法时产生来异常。如果产生异常直接返回,如果没有则进行第3步。
3、通过regFuture.isDone()来判断initAndRegister方法是否执行完毕,如果执行完成返回true,然后调用doBind0进行socket绑定。如果没有执行完成则返回false进行第4步。
4、regFuture会添加一个ChannelFutureListener监听,当initAndRegister执行完成时,调用operationComplete方法并执行doBind0进行socket绑定。
第3、4步同一个目的:调用doBind0方法进行socket绑定。
initAndRegister
| |
这里初始化 children channel。
newChannel分析
上面介绍过,channelFactory 是通过如下方式赋值的
| |
channelFactory.newChannel() 方法的作用是通过 ServerBootstrap 的通道工厂反射创建一个 NioServerSocketChannel。 分析 NioServerSocketChannel 的构造函数:
0、继承关系
NioServerSocketChannel -> AbstractNioMessageChannel -> AbstractNioChannel -> AbstractChannel
1、通过NIO的 SelectorProvider 的 openServerSocketChannel 方法可以得到JDK的 channel,目的是让Netty包装JDK的channel
2、调用父类设置到ch属性中,并设置为非阻塞
| |
3、设置config属性
| |
NioServerSocketChannelConfig 对象,用于对外展示一些配置。 4、调用父构造函数设置unsafe属性、pipeline属性、ChannelId等
| |
NioMessageUnsafe 是在 AbstractNioMessageChannel 中创建的,用于操作消息。(cmd+F12查看NioServerSocketChannel及其父类的newUnsafe方法) DefaultChannelPipeline 管道,是个双向链表结构,用于过滤所有的进出的消息。
init分析
1、init方法是AbstractBootstrap的抽象方法,具体由 ServerBootstrap 实现(上面分析b对象是new ServerBootstrap)
Expand/Collapse Code Block
| |
2、设置 NioServerSocketChannel 的TCP属性,options 3、由于 LinkedHashMap 是非线程安全的,使用同步进行处理
4、对 NioServerSocketChannel 的 ChannelPipeline 添加 ChannelInitializer 处理器,重写 initChannel 方法,该方法通过 addList 向 serverChannel 的流水线处理器重加了一个 ServerBootstrapAcceptor,这是一个接入器,专门接受新请求,并扔给某个事件循环器
5、可以分析出,init 方法的核心作用是和 ChannelPipeline 相关
6、从 NioServerSocketChannel 的初始化过程可以分析出:pipeline是一个双向链表,并且本身初始化了 head 和 tail。ChannelPipeline 的 addLast 方法是将 hanlder 插入到 tail 的前面,tail永远在后面,用于做一些系统的固定工作。
总结
init只是初始化一些基本的配置和属性,以及在pipeline加入一个接入器,用来专门接受新请求,而并没有启动服务。
分析addLast方法
Expand/Collapse Code Block
| |
addLast 最终会调用 addLast0 方法
| |
addList结论: 1、addLast 方法在 DefaultChannelPipeline 类中
2、addList 方法是 pipeline 方法的核心
3、检查该 handler 是否符合标准
4、创建一个 AbstractChannelHandlerContext 对象
说明:ChannelHandlerContext 对象是 ChannelHandler 和 ChannelPipeline 之间的关联,每当有 ChannelHandler 添加到 Pipeline 中时,都会创建Context。
Context的主要功能是管理他所关联的 Handler 和同一个 Pipeline 中的其他 Handler 之间的交互。
5、将 Context 添加到链表中。也就是追加到 tail 节点的前面
6、最后,同步或异步或晚点异步的调用 callHandlerAdded 方法
register channel
继续看 initAndRegister 中的 register
| |
由上文知道这里group为MultithreadEventLoopGroup,其register方法为
| |
这里 next() 可以跟踪到父类初始化 chooser 根据是 executors.length 选择,next方法对length做了一点区别处理。 next的主要作用是因为 NioEventLoopGroup 中维护着多个 NioEventLoop,next方法回调用chooser策略找到下一个 NioEventLoop,并执行该对象的register方法进行注册。
register 实际调用 SingleThreadEventLoop
| |
unsafe上文分析了是 NioMessageUnsafe,unsafe().register方法实际调用 AbstractUnsafe 的方法Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// io.netty.channel.AbstractChannel.AbstractUnsafe#register
@Override
public final void register(EventLoop eventLoop, final ChannelPromise promise) {
if (eventLoop == null) {
throw new NullPointerException("eventLoop");
}
// 判断该 channel 是否已经被注册到 EventLoop 中
if (isRegistered()) {
promise.setFailure(new IllegalStateException("registered to an event loop already"));
return;
}
if (!isCompatible(eventLoop)) {
promise.setFailure(
new IllegalStateException("incompatible event loop type: " + eventLoop.getClass().getName()));
return;
}
// 将 eventLoop 设置到 NioServerSocketChannel 中
AbstractChannel.this.eventLoop = eventLoop;
// 判断当前线程是否为该 EventLoop 中拥有的线程,
// 如果是直接注册,否则添加一个任务到该线程中
if (eventLoop.inEventLoop()) {
register0(promise);
} else {
try {
// 非常重要
eventLoop.execute(new OneTimeTask() {
@Override
public void run() {
register0(promise);
}
});
} catch (Throwable t) {
logger.warn(
"Force-closing a channel whose registration task was not accepted by an event loop: {}",
AbstractChannel.this, t);
closeForcibly();
closeFuture.setClosed();
safeSetFailure(promise, t);
}
}
}
基本逻辑如下: 1、通过 eventLoop.inEventLoop() 判断当前线程是否为该 EventLoop 中拥有的线程,如果是直接注册,否则说明该 EventLoop 在等待并没有执行权。
2、提交一个任务到该线程中,等该 EventLoop 的线程有执行权就会执行该任务(负责调用调用 register0 方法)
重点为 register0 方法。
| |
doRegister方法Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.netty.channel.nio.AbstractNioChannel#doRegister
@Override
protected void doRegister() throws Exception {
boolean selected = false;
for (;;) {
try {
selectionKey = javaChannel().register(eventLoop().selector, 0, this);
return;
} catch (CancelledKeyException e) {
if (!selected) {
// Force the Selector to select now as the "canceled" SelectionKey may still be
// cached and not removed because no Select.select(..) operation was called yet.
eventLoop().selectNow();
selected = true;
} else {
// We forced a select operation on the selector before but the SelectionKey is still cached
// for whatever reason. JDK bug ?
throw e;
}
}
}
}
经上文 newChannel 分析,知道这里javaChannel方法返回的ch为实例化NioServerSocketChannel时产生的 SocketChannelImpl实例,并设置为非阻塞的。
总结
1、initAndRegister 初始化 NioServerSocketChannel 通道并注册各个 handler,返回一个 future
2、通过 ServerBootrap 的通道工厂反射创建一个 NioServerSocketChannel
3、init初始化 NioServerSocketChannel
4、group().register(channel) 通过 ServerBootstrap 的 bossGroup 注册 NioServerSocketChannel
5、最后返回这个异步执行的占位符 regFuture
dobind0方法
| |
这里主要是提交了一个任务到 NioEventLoop 线程中。 提交的任务执行后,然后执行 channel.bind 方法并添加事件,完成 channel 与端口的绑定
executor方法
Expand/Collapse Code Block
| |
inEventLoop 方法判断当前线程是否为该 NioEventLoop 关联的线程,如果是添加任务到任务队列中,不是则先启动线程,然后添加任务到任务队列中去
channel.bind
Expand/Collapse Code Block
| |
findContextOutbound 就是在pipeline所持有的以 AbstractChannelHandlerContext 为节点的双向链表中从尾节点tail开始向前寻找第一个outbound=true的handler节点。 DefaultChannelPipeline 构造器中, 会实例化head和tail两个对象,形成了双向链表的头和尾。 head 是 HeadContext 的实例,实现了 ChannelOutboundHandler 接口和 ChannelInboundHandler 接口,它的 outbound 字段为 true。而tail 是 TailContext 的实例,实现了ChannelInboundHandler 接口,它的 outbound 字段为 false,inbound 字段为true。 因此 findContextOutbound方法 找到的 AbstractChannelHandlerContext 对象其实就是 head。
然后调用 next.invokeBind 方法
| |
继续看 bind 方法,这里是 HeadContext 的
| |
这里的 unsafe 是在 HeadContext 构造函数中赋值的
| |
上文分析了,unsafe 实际上就是 NioMessageUnsafe。 分析 NioMessageUnsafe 的 bind 方法,实际上是 AbstractUnsafe 的方法
Expand/Collapse Code Block
| |
核心代码为 doBind 方法,该方法是 NioServerSocketChannel 中的
| |
javaChannel()方法返回的是NioServerSocketChannel实例初始化时所产生的Java NIO ServerSocketChannel实例(具体为ServerSocketChannelImple实例)。 等价于语句serverSocketChannel.socket().bind(localAddress)完成了指定端口的绑定,然后开始监听此端口。 绑定端口成功后,这里调用了我们自己定义的handler channelActive方法,绑定之前,isActive()方法返回false,绑定之后返回true。
main线程阻塞等待关闭
| |
优雅关闭所有资源
| |
4.3 - IO模型
基本概念
同步异步阻塞非阻塞
同步:当前线程接收返回结果
异步:另外一个线程接收返回结果,多个线程
阻塞:一直等待直到结果返回
非阻塞:干其它事,不用一直等
注意:没有 异步阻塞 这种
IO模型同步异步
通常会说到同步阻塞IO、同步非阻塞IO,异步IO几种术语,所谓阻塞就是发起读取数据请求的时,当数据还没准备就绪的时候,这时请求是即刻返回,还是在这里等待数据的就绪,如果需要等待的话就是阻塞,反之如果即刻返回就是非阻塞。
区分了阻塞和非阻塞后再来分别下同步和异步,在IO模型里面如果请求方从发起请求到数据最后完成的这一段过程中都需要自己参与,那么这种称为同步请求;反之,如果应用发送完指令后就不再参与过程了,只需要等待最终完成结果的通知,那么这就属于异步。
再看同步阻塞、同步非阻塞,他们不同的只是发起读取请求的时候一个请求阻塞,一个请求不阻塞,但是相同的是,他们都需要应用自己监控整个数据完成的过程。而为什么只有异步非阻塞 而没有异步阻塞呢,因为异步模型下请求指定发送完后就即刻返回了,没有任何后续流程了,所以它注定不会阻塞,所以也就只会有异步非阻塞模型了。
5种IO模型

阻塞IO
非阻塞IO
多路复用IO
Java NIO就是多路复用IO
另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。
单线程可以配合Selector完成对多个Channel可读写事件的监控,这称之为多路复用
单线程+事件
- 多路复用仅针对网络IO、普通文件IO没法利用多路复用
- 如果不用Selector的非阻塞模式,线程大部分事件都在做无用功,而Selector能够保证
- 有可连接事件时才去连接
- 有可读事件才去读取
- 有可写事件才去写入
- 限于网络传输能力,Channel未必时时可写,一旦Channel可写,会触发Selector的可写事件
信号驱动IO
异步IO
2种高性能设计模式
- Reactor
- Proactor
IO复用模型
Select
函数签名:
| |
传递给select函数的参数会告诉内核:
- 我们所关心的文件描述符(三类,读、写、异常)
- 对每个描述符,我们所关心的状态
- 我们要等待多长时间 fd_set是bitmap结构,默认长度为1024,由内核中一个常量定义,因此select最多处理的fd长度就是1024,要修改该长度,需要重新编译内核。 fd_set 的使用涉及以下几个 API:
| |
从select函数返回后,内核告诉我们以下信息:
- 对我们的要求已经做好准备的描述符的个数
- 对于三种条件(读、写、异常),哪些描述符已经做好准备 有了这些信息,我们可以调用合适的I/O函数,并且这些函数不会再阻塞。

select的缺点
- fd_set中的bitmap是固定1024位的,也就是说最多只能监听1024个套接字。当然也可以改内核源码,不过代价比较大;
- fd_set每次传入内核之后,都会被改写,导致不可重用,每次调用select都需要重新初始化fd_set;
- 每次调用select都需要拷贝新的fd_set到内核空间,这里会做一个用户态到内核态的切换;
- 拿到fd_set的结果后,应用进程需要遍历整个fd_set,才知道哪些文件描述符有数据可以处理。
Poll
poll 和 select 几乎没有区别。poll 采用链表的方式存储文件描述符,没有最大存储数量的限制。 从性能开销上看,poll 和 select 的差别不大。
epoll
epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。即:
每次 select 需要把监控的 fds 传输到内核里,没有在内核中维护
socket 只唤醒 select,不能告诉它是哪个 socket 改进策略:
在内核里维护了 epoll 管理的 socket 集合,不用每次调用时都把所有管理的 fds 拷贝到内核
引入一个 ready_list 双向链表,callback 里面会把当前的 socket 加入到 ready_list 然后唤醒 epoll,被唤醒的 epoll 只需要遍历 ready_list 即可,这个链表里一定是有数据可读的 socket,相比于 select 就不会做无用的遍历 简而言之,epoll 有以下几个特点:
使用红黑树存储文件描述符集合
使用队列存储就绪的文件描述符
每个文件描述符只需在添加时传入一次;通过事件更改文件描述符状态
select、poll 模型都只使用一个函数,而 epoll 模型使用三个函数:epoll_create、epoll_ctl 和 epoll_wait,我们分开介绍。
epoll_create
| |
epoll_create 会创建一个 epoll 实例,同时返回一个引用该实例的文件描述符。 返回的文件描述符仅仅指向对应的 epoll 实例,并不表示真实的磁盘文件节点。其他 API 如 epoll_ctl、epoll_wait 会使用这个文件描述符来操作相应的 epoll 实例。 当创建好 epoll 句柄后,它会占用一个 fd 值,在 linux 下查看 /proc/进程id/fd/,就能够看到这个 fd。所以在使用完 epoll 后,必须调用 close(epfd) 关闭对应的文件描述符,否则可能导致 fd 被耗尽。当指向同一个 epoll 实例的所有文件描述符都被关闭后,操作系统会销毁这个 epoll 实例。 epoll 实例内部存储:
- 监听列表:所有要监听的文件描述符,使用****红黑树
- 就绪列表:所有就绪的文件描述符,使用****链表
epoll_ctl
| |
epoll_ctl 会监听文件描述符 fd 上发生的 event 事件。 参数说明:
- epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
- fd 表示要监听的目标文件描述符
- event 表示要监听的事件(可读、可写、发送错误…)
- op 表示要对 fd 执行的操作,有以下几种:
- EPOLL_CTL_ADD:为 fd 添加一个监听事件 event
- EPOLL_CTL_MOD:Change the event event associated with the target file descriptor fd(event 是一个结构体变量,这相当于变量 event 本身没变,但是更改了其内部字段的值)
- EPOLL_CTL_DEL:删除 fd 的所有监听事件,这种情况下 event 参数没用
返回值 0 或 -1,表示上述操作成功与否。 epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。
epoll_wait
| |
这是 epoll 模型的主要函数,功能相当于 select。 参数说明:
- epfd 即 epoll_create 返回的文件描述符,指向一个 epoll 实例
- events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请
- maxevents 指定 events 的大小
- timeout 类似于 select 中的 timeout。如果没有文件描述符就绪,即就绪队列为空,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有文件描述符就绪;如果 timeout 设为 0,则 epoll_wait 会立即返回
返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。
epoll 的优点
一开始说,epoll 是对 select 和 poll 的改进,避免了“性能开销大”和“文件描述符数量少”两个缺点。 对于“文件描述符数量少”,select 使用整型数组存储文件描述符集合,而 epoll 使用红黑树存储,数量较大。 对于“性能开销大”,epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此 epoll 不需要像 select 那样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可。这样,在没有描述符就绪时,epoll 能更早地让出系统资源。
相当于时间复杂度从 O(n) 降为 O(1)
此外,每次调用 select 时都需要向内核拷贝所有要监听的描述符集合,而 epoll 对于每个描述符,只需要在 epoll_ctl 传递一次,之后 epoll_wait 不需要再次传递。这也大大提高了效率。
水平触发和边缘触发
水平触发(LT,Level Trigger):当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次还会发出可读/可写信号进行通知。
边缘触发(ET,Edge Trigger):仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。
select 只支持水平触发,epoll 支持水平触发和边缘触发。
区别:边缘触发效率更高,减少了事件被重复触发的次数,函数不会返回大量用户程序可能不需要的文件描述符。
水平触发、边缘触发的名称来源:数字电路当中的电位水平,高低电平切换瞬间的触发动作叫边缘触发,而处于高电平的触发动作叫做水平触发。
举例:
如果一个客户端同时发来了 5 个数据包,正常的逻辑只需要唤醒一次 epoll ,把当前 socket 加一次到 ready_list 就行了,不需要加 5 次。然后用户程序可以把 socket 接收队列的所有数据包都读完。
但假设用户程序只读了一个包,然后处理报错了,后面不读了,那后面的 4 个包怎么处理?
如果是 ET 模式,就无法读取,因为没有把 socket 加入到 ready_list 的触发条件。除非该客户端发了新数据包过来,才会再把当前 socket 加入到 ready_list,在新包过来之前,这 4 个数据包都不会被读到。
而 LT 模式不一样,因为每次读完有感兴趣的事件发生之后,会把当前 socket 再加入到 ready_list,所以下次肯定能读到这个 socket,所以后面的 4 个数据包会被访问到,不论客户端是否发送新包。
Reference
5 - Java
Introduction
Java知识体系及源码分析
5.1 - 基础
Introduction
Java基础
5.1.1 - 双亲委派机制
背景知识
JDK 中默认类加载器有 3 个:
- BootStrapClassLoader
- ExtClassLoader
- AppClassLoader 上述3个前者是后者的父加载器。
定义
双亲委派机制:加载器在加载过程中,先把类交由父类加载器进行加载,父类加载器没找到才优自身加载。
目的
防止内存中存在多份同样的字节码,安全考虑。
类加载规则
如果一个类由类加载器X加载,那么类的依赖类也是由相同的类加载器加载。
打破双亲委派机制
加载类时不是按照从 APPClassLoader -> ExtClassLoader -> BootStrap ClassLoader 的顺序找,就是打破双亲委派机制。
只要不依次往上交由父加载器进行加载,就是打破双亲委派机制。
因为加载class核心方法是 LoaderClass 类的 loadClass 方法(双亲委派机制的核心实现)。所以只要自定义 ClassLoader,重写 loadClass 方法(不依照往上开始寻找类加载器),就是打破双亲委派机制。
案例分析
Tomcat
目的
隔离不同的应用程序,防止类全限名相同导致冲突。
类加载结构图

Web应用层级隔离
Tomcat 给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了 loadClass 方法,优先加载当前应用目录下的类,如果找不到才一层一层往上找。这样就实现了 Web 应用层级的隔离。
应用程序间共享
当然,并不是 Web 应用程序下的所有依赖都需要隔离,如 Redis 等类可以在应用程序中共享(如果需要),版本相同时没必要每个 Web 应用程序都独自加载一份。
实现方式是在 WebAppClassLoader 上加了一个父类加载器(SharedClassLoader),如果 WebAppClassLoader 自身没有加载到某个类,就委托 SharedClassLoader 去加载。
其实就是把需要应用程序之间需要共享的类放到一个共享目录下。
隔离Tomcat类
为了隔离 Web 应用和 Tomcat 自身的类,又有类加载器(CatalinaClassLoader)来加载 Tomcat 本身的依赖。
Tomcat与应用程序共享
如果 Tomcat 本身的依赖和 Web 应用还需要共享,还有类加载器(CommonClassLoader)来加载进而实现共享。
小结
各个类加载器的加载目录可以查看 tomcat 的 catalina.properties 配置文件。
JDBC
目的
JDBC定义了接口,具体实现类由各个厂商来实现(如MySQL等)。
实现
使用 JDBC 时是使用 DriverManager 来获取 Connection,DriverManager 在 java.sql 包中,按理是由 BootStrapClassLoader 来加载,但其无法加载第三方库中的类。当使用 DriverManager.getConnection 时无法加载到各个厂商实现的类。
所以 DriverManager 的解决方案是在其初始化时,得到“线程上下文加载器”,获取 Connection 时,先找 ExtClassLoader 和 BootStrapClassLoader(肯定加载不到),最终由 AppClassLoader 进行加载。
讨论
本来是由 BootStrapClassLoader 进行类加载,但是 JDBC 改成了“线程上下文加载器”加载,但还遵守了:依次往上找父类加载器进行加载,都找不到时才由自身加载。类加载规则没有变化(也有说破坏了,仁者见仁智者见智,了解原理即可)。
5.2 - JVM
Introduction
JVM
5.2.1 - 垃圾回收算法
概述
当进行系统调优与线上问题排查时,深入理解 Java 虚拟机垃圾回收机制的底层原理是非常有必要的。Java 垃圾回收器的种类繁多,其设计主要在吞吐量(内存空间)与实时性(用户线程中断)进行权衡,适应场景也有所区别。本文主要针对 JDK8 进行分析。
内存/堆结构
新生代和老年代默认分配空间比为 1:2。
新生代又可以分为:
- Eden 区
- 两个 Survial 区(S0/S1) 空间占比:Eden(8/10)、S0(1/10)、S1(1/10)

一块独立的内存区域只能使用一种垃圾回收算法,根据对象生命周期特征,将其划到不同区域,再对特定区域使用特定的垃圾回收算法,将垃圾回收算法的优点发挥到极致。这种组合的垃圾回收算法成为分代垃圾回收算法。如:新生代使用标记复制算法,老年代使用标记整理算法。
对象回收算法
判断对象回收一般有两种方法。
引用计数器算法
对象被引用一次计数器加 1,对象取消被引用计数器减 1,计数器为 0 时则表示对象不被引用,可以被销毁。
该算法简单高效。但对于循环引用或者其它复杂情况,需要更多的开销,所以一般不使用该方法。
可达性分析算法
顺着 GCRoot 根向下搜索,只要在引用链上的对象就是可达的,在这之外的对象则不可达,不可达的对象就是可回收的。GCRoots 对象一般包括:
- 虚拟机栈帧上本地变量表中的引用对象(方法参数、局部变量、临时变量)
- 方法区中的静态属性引用类型对象、常量引用对象
- 本地方法栈中的引用对象(Native方法的引用对象)
- Java虚拟机内部的引用对象,如异常对象、系统类加载器等
- 被同步锁(synchronize)持有的对象
- Java 虚拟机内部情况的注册回调、本地缓存等

垃圾回收算法
垃圾具体如何回收涉及到垃圾回收算法。垃圾回收算法可分为3种:
- 标记清除算法
- 标记复制算法
- 标记整理算法
标记清除算法
流程
- 标记出所有需要被回收的对象
- 对标记对象进行统一清除
优点
- 效率高
缺点
- 执行效率不可控(如大部分对象都可回收,收集器需要一一大量执行标记、收集操作)
- 产生大量内存碎片
标记复制算法
流程
标记复制算法将内存分为大小相同的两个区域:
- 运行区域
- 预留区域 所有创建的新对象都分配到运行区域,当该区域内存不足时,则将该区域全部存活对象复制到预留区域,然后再清空整个运行区域内存。此时这两个区域的角色也互相调换。
标记复制算法在存在大量垃圾对象时,只需要复制较少的存活对象,不会产生内存碎片,新内存的分配只需要移动堆顶指针顺序分配即可,很好地兼顾了效率与内存碎片的问题。
优点
- 兼顾效率
- 避免内存碎片
缺点
- 预留一半空间造成浪费
- 少量垃圾大量存活对象时效果很差,复制操作很多但回收空间少
标记整理算法
基于前两种算法的优缺点,又提出了标记整理算法。
- 标记阶段:和其他算法一样
- 整理阶段:将存活的对象向内存空间一端移动,然后将存活对象边界以外的空间全部清空
堆内存回收过程
第一次MinorGC
- Eden 区空间不足时,触发 MinorGC
- 垃圾回收器首先将 Eden 区中存活对象复制到 S0 区中(S0或S1,互备)
- 清空 Eden 区空间
第二次MinorGC
- Eden 区空间不足时,再次触发 MinorGC
- 将 Eden 区存活对象复制到 S1 区
- 清空 Eden 区空间
- 将 S0 区存活对象复制到 S1 区并将对象的年龄加 1
- 清空 S0 区 第三次及之后 MinorGC 同理。
这里为了减少复制算法空白区域的空间浪费,不是将内存一分为二,而是巧妙地将内存分为三个区域,预留的空白区域只占整个年轻代的空间的 1/10。
进入老年代
进入老年代空间的条件:
- 从年轻代晋升
- 年轻代分配担保:Survivor 区只有年轻代空间的 2/10 且一分为二,空间不足以分配所有存活对象时,将无法容纳的对象分配到老年代中
- 对象年龄超过虚拟机 MaxTenuringThreshold 参数大小的对象(Parallel Scavenge:å 15,CMS: 6, G1: 15)
- Survivor 空间中相同年龄所有对象大小的总和大于Survivor空间的一半(TargetSurvivorRatio),大于或等于该年龄的对象直接进入老年代
- 直接分配
- 超过虚拟机 PretenureSizeThreshold 参数大小的大对象
- 超过 Eden 大小的对象
- 新生代分配失败,如大字符串或者大数组
FullGC
老年代空间不足时就会触发 FullGC,FullGC 触发的条件一般有:
- 调用 System.gc()(建议虚拟机执行 FullGC,不一定真正去执行)
- 老年代空间不足
- 空间分配担保失败
- JDK 1.7 及以前的永久代空间不足
- CMS GC 时出现 Concurrent Mode Failure
5.2.2 - JVM调优
优化系统思路
首先排查 DB 的问题 如评估索引是否合理,是否需要引入分布式缓存、是否需要分库分表等
扩容 横向和纵向扩容,解决系统压力过大导致的硬件能力不足出现的问题
代码优化 检查代码上是否存在资源浪费的情况,或者逻辑上有优化的空间,比如通过并行的方式处理请求等等)
JVM排查并优化 观察是否存在多次GC问题或者GC时间过长等等
网络和操作系统层面优化 查看内存/CPU/网络/硬盘读写指标是否正常等
JVM调优
参考指标
- 吞吐量
- 停顿时间
- 垃圾回收频率
调优方法
内存分配策略
内存区域大小以及相关策略(比如整块堆内存占多少、年轻代占多少、老年代占多少、Survivor占多少、晋升老年代的条件等等)
比如 -Xmx:设置堆的最大值、-Xms:设置堆的初始值、-Xmn:表示年轻代的大小、-XX:SurvivorRatio:伊甸区和幸存区的比例等等
按经验来说:IO密集型的可以稍微把「年轻代」空间加大些,因为大多数对象都是在年轻代就会灭亡。内存计算密集型的可以稍微把「老年代」空间加大些,对象存活时间会更长些
垃圾回收器
选择合适的垃圾回收器,以及各个垃圾回收器的各种调优参数
比如(-XX:+UseG1GC:指定 JVM 使用的垃圾回收器为 G1、-XX:MaxGCPauseMillis:设置目标停顿时间、-XX:InitiatingHeapOccupancyPercent:当整个堆内存使用达到一定比例,全局并发标记阶段 就会被启动等等)
调优步骤
- 监控GC状态
- 生成dump文件
- 分析dump文件
- 调整GC类型/内存分配
调优参数参考
堆大小
参数-Xms -Xmx标志限定堆的最小、最大值。为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值。
堆内存分配比例
年轻代和老年代默认比例是 1:2 分配堆内存,可以通过调整二者之间的比例 NewRadio 来调整二者之间的大小。
年轻代通过 -XX:newSize -XX:MaxNewSize 来设置其绝对大小。同样为了防止堆收缩,通常将二者设置成同样大小。
年轻代/老年代大小
更大的年轻代必然导致更小的老年代:大的年轻代会延长普通GC的周期,且增加每次 YGC 的时间。小的老年代导致更频繁的 FullGC。
更小的年轻代必然导致更大的老年代:小的年轻代会导致 YGC 很频繁,但每次 GC 时间会很短。大的老年代会减少 FullGC 的频率。 如何选择应该依赖应用程序的对象生命周期分布情况:
如果应用存在大量的临时对象,应该选择更大的年轻代
如果应用存在相对较多的持久对象,老年代应该适当增大 但是很多应用都没有这样明显的特征。所以抉择时应该根据以下几点:
- 本着 FullGC 尽量少的原则,让老年代尽量缓存常用对象(默认比例1:2也是基于这个原则)
- 观察一段时间,查看峰值时老年代占用内存大小,在不影响 FullGC 的前提下,适当加大年轻代,比如将比例控制成 1:1。但应该给老年代至少预留三分之一的增长空间。
垃圾回收算法
在配置较好的机器上(如多核、大内存),可以为老年代选择并行回收算法:-XX:+UseParallelOldGC。
线程堆栈大小
每个线程默认开启 1M 的堆栈,用于存放栈帧、调用参数、局部变量等。可以根据实际情况调小,理论上总内存大小不变的情况下,这样可以开启更多的线程。当然也受限于操作系统。
调优策略
FullGC
FullGC 会对整个堆进行整理,包括 年轻代、老年代、永久代,所以会比较慢,因此应该尽可能减少 FullGC 的次数。
可能的原因:
- 老年代被写满
- 永久代空间不足
- System.gc() 被显示调用
工具
JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视图监控工具。
jconsole
JMX的可视化管理工具。
用于对 JVM 中的内存、线程和类等进行监控;
jvisualvm
JDK 自带的全能分析工具。
可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。
jps
虚拟机进程状况工具。
用来输出JVM中运行的进程状态「基础」信息(进程号、主类)。这个命令很常用的就是用来看当前服务器有多少Java进程在运行,它们的进程号和加载主类是什么。
jstat
虚拟机统计信息监控工具。
监视虚拟机各种运行状态信息,可以显示本地或者是远程虚拟机进程中「统计类」信息,如类装载、编译相关信息统计、各个内存区域GC概况和统计、垃圾收集、JIT编译等运行数据。这个命令很常用于看GC的情况。
jinfo
通过jinfo命令来查看和调整Java进程的「运行参数」。
jmap
通过jmap命令来查看Java进程的「内存信息」。可以生成虚拟机的内存转储快照(heapdump文件)。常用于将JVM内存信息dump到文件,然后用MAT( Memory Analyzer tool 内存解析工具)对文件进行分析。
jstack
堆栈跟踪工具。
查看JVM「线程信息」,用于生成虚拟机当前时刻的线程快照。这个命令用常用语排查死锁相关的问题。
jhat
分析内存转储快照,不推荐使用,而且慢
Arthas
还有近期比较热门的Arthas(阿里开源的诊断工具),涵盖了上面很多命令的功能且自带图形化界面。这也是常用的排查和分析工具。
JIT优化技术
JIT优化技术比较出名的有两种:方法内联和逃逸分析。
方法内联
把「目标方法」的代码复制到「调用的方法」中,避免发生真实的方法调用。
因为每次方法调用都会生成栈帧(压栈出栈记录方法调用位置等等)会带来一定的性能损耗,所以「方法内联」的优化可以提高一定的性能。
在JVM中也有相关的参数来指定(-XX:MaxFreqInlineSize、-XX:MaxInlineSize)。
逃逸分析
判断一个对象是否被外部方法引用或外部线程访问的分析技术,如果「没有被引用」,就可以对其进行优化,如:
锁消除(同步忽略) 该对象只在方法内部被访问,不会被别的地方引用,那么就一定是线程安全的,可以把锁相关的代码给忽略掉
栈上分配 该对象只会在方法内部被访问,直接将对象分配在「栈」中
Java默认是将对象分配在「堆」中,是需要通过JVM垃圾回收期进行回收,需要损耗一定的性能,而栈内分配则快很多
- 标量替换/分离对象 当程序真正执行的时候可以不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。
调优案例
内存分配大小
现象:
部分机器出现异常:GC overhead limit exceeded
该异常代表GC为了释放很小的空间却耗费了太多的时间,原因一般有两个:
- 堆太小
- 有死循环或大对象
因为是部分机器,所以排查原因2,怀疑是部分机器中堆设置太小,通过
ps -ef | grep java查看内存分配大小(如堆-Xms -Xmx),如果太小而该应用又比较吃内存,适当调大堆中各区域大小。
内存分配比例
现象:
系统经常出现卡顿
查看GC情况:
| |
可以看到不同GC耗时和次数,Young GC 执行了 56 次,耗时 2.032 秒,平均耗时 36 ms 在正常范围内。FullGC 执行了 6 次,耗时 7.012 秒,平均耗时超过1秒。可以判断是 FullGC 耗时较长导致的。进一步查询年轻代和老年代的大小比例 NewRatio=9,可以得出原因:
- 年轻代太小,导致对象提前进入老年代,触发老年代发生 FullGC
- 老年代太大,进行 FullGC 耗时较大 优化方法:调整 NewRatio 大小。
这样就是把对象控制在年轻代就清理掉,防止进入老年代。
对象未释放
现象:
性能测试过程中,发现内存占用率高,Full GC 频繁。
使用 sudo -u admin -H jmap -dump:format=b,file=FileName.hprof pid 来 dump 内存生成 dump 文件,并使用 MAT 进行分析。
可能得问题:内存泄漏
1、某数据结构(列表、队列等)引用的大量对象未被释放,导致整个线程占用内存过高(几百兆)。优化相关代码即可。
2、匿名对象引用的对象未被释放
元数据空间太小
查看GC log,发现 FullGC 时,老年代占据的内存比例很小(如低于60%)却发生了 FullGC,metaspace 占用的空间远大于默认的分配值。
Reference
5.3 - 并发
Introduction
Java并发
5.3.1 - Java并发编程的艺术
Java内存模型
Java内存模型的基础
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
重排序类型
3种指令重排序
- 编译器优化的重排序
- 指令级并行的重排序
- 内存系统的重排序 第1种属于编译器重排序,第2、3种属于处理器重排序。
对于编译器重排序:JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类。
其中StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。但该屏障开销较大,要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
happens-before简介
密切相关的happens-before规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且 B happens-before C,那么A happens-before C。
happens-before的定义很微妙,两个操作之间具有happens-before,并不意味着前一个操作必须要在后一个操作之前执行!!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)
重排序
数据依赖性
两个操作访问同一个变量,且两个操作中有一个为写操作,此时这两个操作之间存在数据依赖性。数据依赖分为3种:写后读、写后写、读后写。
这里的数据依赖性仅对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
不会对存在数据依赖关系的操作做重排序,但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
程序顺序规则
即代码顺序。但JMM允许对结果无影响的重排序。
重排序对多线程的影响
顺序一致性
顺序一致性内存模型
理想化的概念
两大特性:
1、一个线程中的所有操作必须按照程序的顺序来执行
2、(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
在任意时间点最多只能有一个线程可以连接到内存(串行化)
但是,在JMM中没有这个保证。
同步程序的顺序一致性效果
**顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。**JMM会在退出临界区和进入临界区这两个关键时间点做一些特殊处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。
由于监视器互斥执行的特性线程之间无法“观察”到对方在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
未同步程序的执行特性
要么之前写入的值,要么默认值,不会无中生有。
未同步程序在两个模型中的差异:
1、顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行
2、顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有的线程能看到一致的操作执行顺序。
3、JMM不保证对64位long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写都具有原子性。(总线的工作机制密切相关,32位处理器)
注意:在JSR-133之前的旧内存模型中,64位long/double型变量的读/写操作均可被才分为两个32位的读/写操作。从JSR-133内存模型开始(JDK5),仅仅只允许把64位long/double型变量的写操作拆分为两个32位的写操作来执行,但任意的读操作必须具有原子性(即任意读操作必须要在单个读事务中执行)。
volatile的内存语义
volatile变量自身具有下列特性:
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性 volatile读的内存语义:
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从内存中读取共享变量。
volatile写和volatile读的内存语义总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息
- 线程B读一个volatile变量,实质上是线程B接受了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息 volatile重排序规则:
1、第2个操作是volatile写时,无论第1个操作是什么,都不能重排序
2、第1个操作是volatile读时,无论第2个操作是什么,都不能重排序
3、第1个操作是volatile写,第二个操作是volatile读时,不能重排序
基于保守策略的JMM内存屏障插入策略(可以保证任意处理平台,任意程序中都能得到正确的volatile内存语义)
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
注意:第2条,也可以是每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个volatile写操作的后面插入一个StoreLoad屏障
第3、4条策略非常保守,但在实际执行中,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障
JSR-133为什么要增强volatile的内存语义
增强:新的java内存模型中不允许volatile变量与普通变量重排序
确保volatile的写-读和锁的释放-获取 具有相同的内存语义
(个人理解)提醒:普通读、写可能用的是volatile的赋值,所以需要增加内存屏障隔离。
锁的内存语义
实际上和volatile差不多,也是将本地内存置为无效,刷新主内存等等。
锁释放和锁获取的内存语义总结:
- 线程A释放一个锁,是指上市线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
- 线程A释放锁,随后线程B获取这个锁,这个过程实际上是线程A通过主内存向线程B发送消息。
锁内存语义的实现
final域的内存语义
final域的重排序规则
对于final域,编译器和处理器要遵守两个重排序规则
1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
2、初次读一个final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
写final域的重排序规则
1、JMM禁止编译器把final域的写重排序到构造函数之外
2、编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
读final域的重排序规则
在一个线程中,初次读该对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障(间接依赖关系)。
JSR-133为什么增强final的语义
防止读到final默认值
保证只要对象是正确构造的(被构造对象引用在构造函数中没有“逸出”),不需要使用同步(lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。
happens-before
happens-before规则
1、程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
2、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
3、volatile变量规则:对一个volatile域的写,happens-before于随后对这个锁的加锁
4、传递性:略
5、start()规则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happen-before于线程B中的任意操作
6、join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
双重检查锁定与延迟初始化
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第一个判空读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
解释:即线程A初始化化instance时,线程B可能访问到一个未完成初始化的instance,从而造成错误。加锁只能保证线程B不进入锁住的代码,即不再次初始化instance,但不能限制线程B获取instance。
问题根源:
instance=new Singleton()这行代码可以分解为如下3行伪代码:
| |
这3行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上)。 解决方案:
1、禁止第2和第3行伪代码重排序:volatile修饰instance
优势:volatile除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
2、允许第2和第3行伪代码重排序,但不允许其他线程“看到”这个重排序:内部类
JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过
Java立即初始化类场景
根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:
1)T是一个类,而且一个T类型的实例被创建
2)T是一个类,且T中声明的一个静态方法被调用
3)T中声明的一个静态字段被赋值
4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段
5)T是一个顶级类(Top Level Class),而且一个断言语句嵌套在T内部被执行
Java并发编程基础
线程简介
线程优先级
整形成员变量priority来控制优先级,范围从1-10,可通过setPriority(int)来修改优先级,默认为5,优先级高的线程分配时间片的数量要多余优先级低的线程。设置线程优先级时,针对频繁阻塞(休眠或I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。
线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java线程对于优先级的设定。
Daemon线程
支持型线程,主要被用作程序中后台调度以及支持性工作。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。
Daemon属性需要在启动线程之前设置,不能再启动线程之后设置。
Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。
在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。
理解中断
从Java的API中可以看到,许多声明抛出InterruptedException的方法(例如Thread.sleep(long millis)方法)这些方法在抛出InterruptException之前,Java虚拟机会先将该线程的中断标志位清除,然后抛出InterruptedException,此时调用isInterrupted()方法将会返回false。
过期的suspend()、resume()、stop()
suspend()、resume()、stop()方法完成了线程的暂停、回复和终止工作。但是这些API是过期的,不建议使用。
不建议使用原因主要有:以suspend()方法为例,再调用后线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源工作的机会,因此可能会导致程序可能工作在不确定状态下。
暂停和恢复操作可以使用等待/通知机制来替代
安全地终止线程
中断最适合用来取消或停止任务。或者利用一个boolean变量(自定义)来控制是否需要停止任务并终结该线程。
线程间通信
volatile和synchronized关键字
等待/通知机制
使用共享变量+sleep()(休眠用来防止过快的“无效”尝试)看似能够实现所需功能,但存在如下问题:
1、难以确保及时性:休眠时长
2、难以降低开销:降低时长消耗更多处理器资源
Java通过内置的等待/通知机制能够很好的解决以上问题
等待/通知的相关方法是任意Java对象都具备的,定义在Object类上。
notify():通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁。
调用wait()、notify()以及notifyall()时需要注意的细节:
1、使用wait()、notify()、notifyAll()时需要先对调用对象加锁
2、调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
3、notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要notify()或者notifyAll()的线程释放锁之后,等待线程才有机会从wait()返回。
4、notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列中,被移动的线程状态由WAITING变为BLOCKED。
5、从wait()方法返回的前提是获取调用对象的锁。
等待/通知的经典范式
等待方遵循如下原则:
1、获取对象的锁
2、如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
3、条件满足则执行对应的逻辑
| |
通知方遵循如下原则: 1、获得对象的锁
2、改变条件
3、通知所有等待在对象上的线程
| |
管道输入/输出流
管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。
管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。
Thread.join()的使用
线程A调用了thread.join()语句的含义:当前线程A等待thread线程终止之后才从thread.join()返回。还有join(long millis)和join(long millis, int nanos)两个具备超时特性的方法。
join源码与上述总结的等待/通知经典范式一致,即加锁、循坏和处理逻辑3个步骤。
ThreadLocal的使用
线程应用实例
等待超时模式
数据库连接池示例
Java中的锁
Lock接口
Lock接口提供的synchronized关键字不具备的主要特性
1、尝试非阻塞地获取锁(tryLock)
当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取锁,则成功获取并持有锁
2、能被中断地获取锁(lockInterruptibly)
与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程能被中断异常将会被抛出,同时锁会被释放
3、超时获取锁
队列同步器
队列同步器的接口与实例
同步器可重写的方法
1、tryAcquire
2、tryRelease
3、tryAcquireShared
4、tryReleaseShared
5、isHeldExclusively:当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所独占
同步器提供的模板方法
1、acquire
2、acquireInterruptibly:响应中断
3、tryAcquireNanos:在acquireInterruptibly基础上增加了超时限制,获取到返回true
4、acquireShared
5、acquireSharedInterruptibly
6、tryAcquireSharedNanos
7、release
8、releaseShared
9、getQueuedThreads:获取等待在同步队列中的线程集合
同步器提供的模板方法基本分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。
队列同步器的实现分析
主要包括:同步队列、独占式同步状态获取与释放、共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。
1、同步队列
2、独占式同步状态获取与释放
acquire,该方法对中断不敏感
3、重入锁
公平锁多一个hasQueuedPredecessors()方法判断,即加入了同步队列中当前节点是否有前驱节点的判断。
非公平锁的上下文切换次数比公平锁低,效率更高。
读写锁
ReadWriteLock仅定义了获取读锁和写锁的两个方法,即readLock()和writeLock()方法。而其实现——ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,如下:
1、getReadLockCount:返回读锁被获取的次数。该次数不等于获取读锁的线程数(可重入。
2、getReadHoldCount:返回当前线程获取读锁的次数。该方法在Java6中加入到ReentrantReadWriteLock中,使用ThreadLocal保存当前线程获取的次数
3、isWriteLocked:判断写锁是否被获取
4、getWriteHoldCount:返回当前写锁被获取的次数
读写状态的设计
读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。
如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量。读写锁将变量切分成了两个部分,高16位表示读,低16位表示写。
状态划分的推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)(无符号补0右移16位)大于0,即读锁已被获取
写锁的获取与释放
写锁是一个支持重进入的排它锁。
读锁的获取与释放
锁降级
锁降级是指写锁降级成为读锁。如果当前线程用油写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
锁降级中读锁的获取是否必要?
必要的。保证数据的可见性,如果不获取读锁而是直接释放写锁,假设另一线程T修改了数据,那么当前线程无法感知线程T的数据更新
RentrantReadWriteLock不支持锁升级。
LockSupport工具
Condition接口
Object的监视器方法和Condition接口对比:
| 对比项 | Object Monitor Methods | Condition |
|---|---|---|
| 前置条件 | 获取对象的锁 | 调用Lock.lock()获取锁 调用Lock.newCondition()获取Condition对象 |
| 调用方式 | 直接调用 如:object.wait() | 直接调用 如:condition.await() |
| 等待队列个数 | 一个 | 多个 |
| 当前线程释放锁并进入等待状态 | 支持 | 支持 |
| 当前线程释放锁并进入等待状态, 在等待状态中不响应中断 | 不支持 | 支持 |
| 当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
| 当前线程释放锁并进入等待状态到将来的某个时间 | 不支持 | 支持 |
| 唤醒等待队列的一个线程 | 支持 | 支持 |
| 唤醒等待队列的全部线程 | 支持 | 支持 |
Condition接口与示例
Condition对象是有Lock对象(调用Lock对象的newCondition()方法)创建出来的。Condition依赖Lock对象。
Condition的实现分析
主要包括:等待队列、等待和通知。
在Object的监视器模型上,一个对象拥有一个同步队列和等待队列。而并发包中的Lock(更确切地说是同步器)拥有一个同步队列和多个等待队列。
等待
调用Condition的await()方法(或以await开头的方法),会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态。
如果从队列(同步队列和等待队列)的角度看await()方法,当调用await()方法时,相当于同步队列的首节点(获取了锁的节点)移动到了Condition的等待队列中。
**当等待队列中的节点被唤醒,则唤醒节点的线程开始尝试获取同步状态。**如果不是通过其他线程调用Condition.signal()方法唤醒,而是对等待线程进行中断,则会抛出InterruptedException。
通知
调用Condition的signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在唤醒节点之前,会将节点移到同步队列中,然后加入到获取同步状态的竞争中。
Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal()方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程。
Java并发容器和框架
ConcurrentHashMap
ConcurrentLinkedQueue
Java中的阻塞队列
Fork/Join框架
工作窃取算法
双端队列
Java中的13个原子操作类
原子更新基本类型类
- AtomicBoolean
- AtomicInteger
- AtomicLong
原子更新数组
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
- AtomicIntegerArray
原子更新引用
- AtomicReference
- AtomicReferenceFieldUpdater
- AtomicMarkableReference
原子更新字段类
- AtomicIntegerFieldUpdater
- AtomicLongFieldUpdater
- AtomicStamedReference
Java中的并发工具类
等待多线程完成的CountDownLatch
同步屏障CyclicBarrier
控制并发线程数的Semaphore
线程间交换数据的Exchanger
Java中的线程池
Executor框架
Java并发编程实战
5.4 - Web
Introduction
Java web相关,如JSP、tomcat等
5.5 - 框架
Introduction
Java框架
5.5.1 - Mybatis
Introduction
Mybatis
5.5.2 - Spring
Introduction
Spring
5.5.2.1 - 01.Spring基础知识
基础
什么是 Spring Boot Starters
解决引用依赖配置繁琐的问题。
Spring Boot坚信“约定大于配置”理念。
使用ConfigurationProperties和AutoConfiguration配置。starter的ConfigurationProperties还使得所有的配置属性被聚集到一个文件中(一般在resources目录下的application.properties)

Spring Boot 支持哪些内嵌 Servlet 容器
- tomcat
- jetty
- undertow
如何在spring容器中使用Jetty而不是Tomcat
在pom.xml中排除tomcat引入jetty即可
| |
介绍一下@SpringBootApplication注解
见注解专题
SpringBoot的自动装配是如何实现的
见自动装配专题
开发RESTful web常用的注解有哪些
- @GetMapping
- @PostMapping
- @RequestMapping
- @ResponseBody
springboot常用的两种配置文件
- properties
- yaml
常见的Bean映射工具
- BeanUtils
- BeanCopier
- Dozer
- Orika
Ref:https://www.cnblogs.com/songhaibin/p/13382799.html
springboot如何监控系统运行状况
Spring boot Actuator提供了一组基于HTTP和JMX内置的EndPoints用于在系统运行时监控系统的运行情况。详情可以参见Spring boot官网在此列举一些常用的内置EndPoints
springboot如何做请求参数校验
Bean Validation
| |
springboot的验证框架提供了一系列的注解用于验证参数,完整的注解可以去javax.validation.constraints这个包下看。这里整理一下:
| 注解 | 说明 |
|---|---|
| @AssertFalse | 被注释的元素必须为 false |
| @AssertTrue | 被注释的元素必须为 true |
| @DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
如何使用springboot实现全局异常处理
SpringBoot中有一个ControllerAdvice的注解,使用该注解表示开启了全局异常的捕获,我们只需在自定义一个方法使用ExceptionHandler注解然后定义捕获异常的类型即可对这些捕获的异常进行统一的处理。
Ref: SpringBoot全局异常准备
springboot中如何实现定时任务
- 自带的定时任务处理器 @Scheduled 注解
- 第三方框架 Quartz Ref: Spring Boot 中实现定时任务的两种方式
ClassXmlAplicationContext和FileSystemXmlApplicationContext的区别
Ref:https://www.cnblogs.com/sxdcgaq8080/p/5650404.html
classpath与filepath
Spring容器
ApplicationContext通常的实现是什么
- FileSystemXmlApplicationContext
- ClassPathXmlApplicationContext
- WebXmlApplicationContext
BeanFactory 和 ApplicationContext有什么区别
1、依赖关系
ApplicationContext接口作为BeanFactory的派生,除了提供BeanFactory所具有的功能外,还提供了更完整的框架功能:
- 继承MessageSource,因此支持国际化。
- 统一的资源文件访问方式。
- 提供在监听器中注册bean的事件。
- 同时加载多个配置文件。
- 载入多个(有继承关系)上下文 ,使得每一个上下文都专注于一个特定的层次,比如应用的web层。
2、加载方式
BeanFactory采用延迟加载的方式,只有在使用到某个Bean时(调用getBean()),才对该Bean进行加载实例化。这样,我们就不能发现一些存在的Spring的配置问题。如果Bean的某一个属性没有注入,BeanFacotry加载后,直至第一次使用调用getBean方法才会抛出异常。
ApplicationContext,它是在容器启动时,一次性创建了所有的Bean。这样,在容器启动时,我们就可以发现Spring中存在的配置错误,这样有利于检查所依赖属性是否注入。
3、创建方式
BeanFactory通常以编程的方式被创建,ApplicationContext还能以声明的方式创建,如使用ContextLoader。
4、注册方式
BeanFactory和ApplicationContext都支持BeanPostProcessor、BeanFactoryPostProcessor的使用,但两者之间的区别是:BeanFactory需要手动注册,而ApplicationContext则是自动注册。
Spring自动注册与自动装配
1)context: annotation-config/>
这个标签会自动向Spring容器注册以下四个BeanPostProcessor, 让系统识别对应的注解从而支持相关的自动装配:
| BeanPostProcessor | 对应的注解 |
|---|---|
| AutowiredAnnotationBeanPostProcessor | @Autowired |
| CommonAnnotationBeanPostProcessor | @Resource @PostConstruct @PreDestroy |
| PersistenceAnnotationBeanPostProcessor | @PersistenceUnit @PersistenceContext |
| RequiredAnnotationBeanPostProcessor | @Required |
传统的注册方式:
| |
这个标签只支持自动装配,不支持自动注册(因为不能识别@Component, @Controller, @Service, @Repository;要想识别这四个注解,需要配置<context: component-scan base-package="xxx.xxx"/>标签, 见2。)。 2)<context: component-scan base-package="xxx.xxx"/>
这个标签包含了<context: annotation-config/>的功能;既支持自动装配,又支持自动注册。
支持@Component, @Controller, @Service, @Repository, @RestController, @ControllerAdvice, @Configuration注解。
作用:扫描base-package并在application context中注册扫描到的使用了以上注解的beans。
注意:Spring容器默认关闭注解装配。可以使用1或2开启注解装配。
3)mvc: annotation-driven
这个标签会自动注册以下bean:
- DefaulAnnotationHandlerMapping
- AnnotationMethodHandlerAdapter
这是Spring MVC为@Controller, @RequestMapping分发请求所必需的。
Ref: Spring自动注册及自动装配
自动装配的局限性
重写:你仍需用 和 配置来定义依赖,意味着总要重写自动装配。
基本数据类型:你不能自动装配简单的属性,如基本数据类型,String字符串,和类。
模糊特性:自动装配不如显式装配精确,如果有可能,建议使用显式装配。
Spring Beans
Spring支持的几种bean的作用域
Spring框架支持以下五种bean的作用域:
singleton bean在每个Spring ioc 容器中只有一个实例。
prototype 一个bean的定义可以有多个实例。
request 每次http请求都会创建一个bean,该作用域仅在基于web的Spring ApplicationContext情形下有效。
session 在一个HTTP Session中,一个bean定义对应一个实例。该作用域仅在基于web的Spring ApplicationContext情形下有效。
global-session 在一个全局的HTTP Session中,一个bean定义对应一个实例。该作用域仅在基于web的Spring ApplicationContext情形下有效。
Spring框架中的单例bean是线程安全的吗
不是,实际上大部分时候 spring bean 无状态的(比如 dao 类)。
Bean的生命周期
- 创建对象
- BeanDefinition
- 填充属性
- 回调aware方法
- 初始化(InitializingBean接口)
- aop 单例池(Map)
Spring中的 bean生命周期**?**
生命周期图

周期流程图

Bean的声明周期流程
Bean 容器找到配置文件中 Spring Bean 的定义。
Bean 容器利用 Java Reflection API 创建一个Bean的实例。
如果涉及到一些属性值 利用 set() 方法设置一些属性值。
如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName() 方法,传入Bean的名字。 如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader() 方法,传入ClassLoader 对象的实例。
与上面的类似,如果实现了其他 *.Aware 接口,就调用相应的方法。 如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执 行 postProcessBeforeInitialization() 方法
如果Bean实现了 InitializingBean 接口,执行 afterPropertiesSet() 方法。
如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。
如果有和加载这个 Bean的 Spring 容器相关的 BeanPostProcessor 对象,执 行 postProcessAfterInitialization() 方法
当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。 当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指 定的方法。
Ref: Spring Bean的声明周期(Demo)
spring的策略设计模式
spring在初始化bean的过程中,需要大量调用BeanPostProcessor后置处理器,所有的后置处理器都有各自的实现方法
spring循环依赖
三级缓存,1、2、半成品的工厂(代理,用于定制bean)Map;3、三级缓存用于直接拿去成品的bean,不用重复之前复杂的过程
单例池(singleObjects)
单例对象只会实例化一次所以需要一个单例池来缓存
单例构造函数依赖和原型构造函数依赖
Ref: https://mp.weixin.qq.com/s/PmLWtSBr8PlmbZ0rOJ1ncw
FactoryBean和ObjectFactory
FactoryBean和ObjectFactory都是用来取得Bean,但使用的方法和地方不同,FactoryBean被配置好后,Spring调用getObject()方法来取得Bean,ObjectFactory配置好后,在Bean里面可以取得ObjectFactory实例,需要我们手动来调用getObject()来取得Bean。
Ref: BeanFactory和Factory Bean的区别
Spring如何实现单例
单例注册表+锁
https://www.cnblogs.com/twoheads/p/9723543.html
InitializingBean接口
参考:https://blog.csdn.net/weixin_30808575/article/details/96162918
其方法afterPropertiesSet是在init-method方法之前执行。
而BeanPostProcessor接口的postProcessBeforeInitialization方法在afterPropertiesSet方法之前执行
BeanPostProcessor,针对所有Spring上下文中所有的bean,可以在配置文档applicationContext.xml中配置一个BeanPostProcessor,然后对所有的bean进行一个初始化之前和之后的代理。 BeanPostProcessor接口中有两个方法: postProcessBeforeInitialization和postProcessAfterInitialization。 postProcessBeforeInitialization方法在bean初始化之前执行, postProcessAfterInitialization方法在bean初始化之后执行。
@PostConstruct
通过 debug 和调用栈找到类InitDestroyAnnotationBeanPostProcessor, 其中的核心方法,即 @PostConstruct 方法调用的入口:
从命名上,我们就可以得到某些信息——这是一个BeanPostProcessor。想到了什么?在也谈Spring容器的生命周期中,提到过BeanPostProcessor的postProcessBeforeInitialization是在Bean生命周期中afterPropertiesSet和init-method之前被调用的。另外通过跟踪,@PostConstruct方法的调用方式也是通过发射机制。
- spring bean的初始化执行顺序:构造方法 -->
@PostConstruct注解的方法 -->afterPropertiesSet方法 -->init-method指定的方法。具体可以参考例子 afterPropertiesSet通过接口实现方式调用(效率上高一点),@PostConstruct和init-method都是通过反射机制调用 构造方法 -> @Autowired -> @PostConstruct
https://www.cnblogs.com/pipicai96/p/11718761.html

BeanPostProcessor和BeanFactoryPostProcessor
参考书P155
BeanFacotoryProcessor的处理要区分两种情况:
- 通过硬编码方式的处理
- 通过配置文件方式的处理 不但要实现注册功能,还要实现对后处理器的激活操作,所以需要载入配置中的定义,并进行激活
对于BeanPostProcessor并不需要马上调用,且硬编码的方式实现的功能是将后处理器提取并调用,这里不需要调用,当然不需要考虑硬编码的方式了,这里的功能只需要将配置文件的BeanPostProcessor提取出来并注册进入beanFacotry就可以了。
ApplicationListener
参考:https://blog.csdn.net/wo541075754/article/details/71720984
在一些业务场景中,当容器初始化完成之后,需要处理一些操作,比如一些数据的加载、初始化缓存、特定任务的注册等等。这个时候我们就可以使用Spring提供的ApplicationListener来进行操作。
代理
【Spring IOC---AOP代理对象生成的时机_gongsenlin341的博客-CSDN博客_spring 代理对象什么时候生成】https://blog.csdn.net/gongsenlin341/article/details/111240114
【在spring中获取代理对象代理的目标对象工具类 - 养眼大魔王 - 博客园】https://www.cnblogs.com/damowang/p/4172733.html
事务
Spring支持两种类型的事务管理
编程式事务管理:这意味你通过编程的方式管理事务,给你带来极大的灵活性,但是难维护。
声明式事务管理:这意味着你可以将业务代码和事务管理分离,你只需用注解和XML配置来管理事务。
spring事务不生效原因
https://zhuanlan.zhihu.com/p/101396825
https://mp.weixin.qq.com/s/SW7vEoEZcvwzBERWDV7HUQ
- 调用自身方法
- 方法不是public
- 发生错误异常(默认回滚的是RuntimeException,如果是其他异常想要回滚,需要在@Transactional注解上加rollbackFor属性)
- 数据库不支持事务毕竟spring事务用的是数据库的事
如何保证事务获取同一个Connection
ThreadLocal中
| |
spring的事务和数据库的事务隔离是一个概念吗
概念一样,数据库有四种隔离级别。spring在此基础上抽象出一种隔离级别为default,表示以数据库默认配置为主。
but如果spring配置的隔离级别与数据库不一致,以spring配置的为主。因为JDBC有一个接口:
| |
该接口用来设置事务的隔离级别。那么在DataSourceUtils中,有一段代码是这样的
| |
Spring的事务传播行为
① PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。
② PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行。
③ PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。
④ PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
⑤ PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
⑥ PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
⑦ PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按REQUIRED属性执行。
事务传播机制
举例:一个方法开启了事务,调用另一开启事务的方法,当传播行为不一样时,事务怎么执行?有几个事务
事务多线程如何生效
参考:https://blog.csdn.net/u010963948/article/details/80367020
总结:事务里开多线程,事务不生效(只能手动设置线程setUncaughtExceptionHandler方法异常时手动回滚)线程中开事务生效。
数据库连接和java多线程的关系
参考:https://blog.csdn.net/luanlouis/article/details/90760372
Java中,当然一个线程可以在整个生命周期独占一个java.sql.Connection,使用该对象完成各种数据库操作,因为一个线程内的所有操作都是同步的和线性的。然而,在实际的项目中,并不会这样做,原因有两个:
- Java中的线程数量可能远超数据库连接数量,会出现僧多粥少的情况
- Java线程在工作过程中,真正访问JDBC数据库连接所占用的时间比例很短
结合上述的两个症结,为了提高JDBC数据库连接的使用效率,目前普遍的解决方案是:当线程需要做数据库操作时,才会真正请求获取JDBC数据库连接,线程使用完了之后,立即释放,被释放的JDBC数据库连接等待下次分配使用
基于这个结论,会衍生两个问题需要解决:
1、Java多线程访问同一个java.sql.Connection会有什么问题?如何解决? 1)事务错乱——锁解决
2)提交其它事务未提交的事务——确保每个线程在使用Connection对象时,最终要明确对Connection做commit 或者rollback。
2、JDBC数据库连接 如何管理和分配? 连接池
5.5.2.2 - 04.Spring定时任务
概述
有时候需要一些定时任务完成一些功能
@Scheduled注解用于定时计划完成一些任务。
Scheduled定时任务器
Scheduled简介
| |
spring.context依赖下的包org.springframework.scheduling,该包有4个子包: 1、annotation
定义了调度、异步任务相关的注解和解析类,常用的注解如@Async、@EnableAsync、@EnableScheduling和@Scheduled。
2、concurrent
定义了调度任务执行器和相对应的FactoryBean
3、config
定义了配置解析、任务具体实现类、调度任务XML配置文件解析相关的解析类。4、4、support
定义了反射支持类、Cron表达式解析器等工具类。
Scheduled使用
1、依赖
如果想单独使用Scheduling,只需要引入spring-context这个依赖。
springboot引入spring-boot-starter-web已经集成了spring-context,可以直接使用Scheduling模块。
2、在需要定期执行的方法上标注 @Schduled
| |
3、开启Schedule 开启Scheduling模块支持只需要在某一个配置类中添加@EnableScheduling注解即可,一般为了明确模块的引入,建议在启动类中使用此注解。
| |
注意:默认是单线程执行,即同一时刻只有一个定时任务。如果一个定时任务是一直在运行,则其它定时任务将被阻塞一直无法运行。
触发规则
1、TriggerTask:动态定时任务。通过Trigger#nextExecutionTime 给定的触发上下文确定下一个执行时间。
2、CronTask:动态定时任务,TriggerTask子类。通过cron表达式确定的时间触发下一个任务执行。
3、IntervalTask:一定时间延迟之后,周期性执行的任务。
多线程使用
| |
注意事项
1、其它注入不同名的TaskScheduler的bean
2、自动注入扫描路径
3、注入优先级
4、注解方法为public
Quartz定时任务框架
Quartz的使用思路
1、job:任务
2、Trigger:触发器
3、Scheduler:任务调度
Quartz使用
1、依赖
| |
2、创建执行任务
| |
3、配置触发器并启动任务
| |
Springboot整合Quartz使用
1、依赖
| |
2、创建执行任务QuartzDemo 同上
3、配置触发器Bean
Expand/Collapse Code Block
| |
4、启动
| |
Scheduled原理
Scheduling模块工作流程

Scheduling模块的核心逻辑在ScheduledAnnotationBeanPostProcessor和ScheduledTaskRegistrar两个类中
入口
EnableScheduling注解
| |
然后看导入的类 SchedulingConfiguration 这里有个技巧:Spring内部加载的Bean一般会定义名称为internalXXX,Bean的role会定义为ROLE_INFRASTRUCTURE = 2
| |
看 ScheduledAnnotationBeanPostProcessor 构造方法,初始化了 register
| |
ScheduledAnnotationBeanPostProcessor
实现的接口
ScheduledTaskHolder
返回Set
MergedBeanDefinitionPostProcessor接口:Bean定义合并时回调,预留空实现,暂时不做任何处理。
BeanPostProcessor
也就是MergedBeanDefinitionPostProcessor的父接口,Bean实例初始化前后分别回调,其中,后回调的postProcessAfterInitialization()方法就是用于解析@Scheduled和装载ScheduledTask,需要重点关注此方法的逻辑。
DestructionAwareBeanPostProcessor
具体的Bean实例销毁的时候回调,用于Bean实例销毁的时候移除和取消对应的任务实例。
Ordered接口:用于Bean加载时候的排序,主要是改变ScheduledAnnotationBeanPostProcessor在BeanPostProcessor执行链中的顺序。
EmbeddedValueResolverAware
回调StringValueResolver实例,用于解析带占位符的环境变量属性值。
BeanNameAware
回调BeanName。
BeanFactoryAware
回调BeanFactory实例,具体是DefaultListableBeanFactory,也就是熟知的IOC容器。
ApplicationContextAware
回调ApplicationContext实例,也就是熟知的Spring上下文,它是IOC容器的门面,同时是事件广播器、资源加载器的实现等等。
SmartInitializingSingleton
所有单例实例化完毕之后回调,作用是在持有的applicationContext为NULL的时候开始调度所有加载完成的任务,这个钩子接口十分有用,常用做一些资源初始化工作。
ApplicationListener
监听Spring应用的事件,具体是ApplicationListener
DisposableBean
当前Bean实例销毁时候回调,也就是ScheduledAnnotationBeanPostProcessor自身被销毁的时候回调,用于取消和清理所有的ScheduledTask。
@Scheduled注解解析
在postProcessAfterInitialization方法中
Expand/Collapse Code Block
| |
processScheduled方法就是具体的注解解析和Task封装的方法。 该方法主要做了4件事:
1、解析@Scheduled中的initialDelay、initialDelayString属性,适用于FixedDelayTask或者FixedRateTask的延迟执行
2、优先解析@Scheduled中的cron属性,封装为CronTask,通过ScheduledTaskRegistrar进行缓存
3、解析@Scheduled中的fixedDelay、fixedDelayString属性,封装为FixedDelayTask,通过ScheduledTaskRegistrar进行缓存
4、解析@Scheduled中的fixedRate、fixedRateString属性,封装为FixedRateTask,通过ScheduledTaskRegistrar进行缓存
Expand/Collapse Code Block
| |
@Scheduled修饰的某个方法如果同时配置了cron、fixedDelay|fixedDelayString和fixedRate|fixedRateString属性,意味着此方法同时封装为三种任务CronTask、FixedDelayTask和FixedRateTask。解析xxString值的使用,用到了EmbeddedValueResolver解析字符串的值,支持占位符,这样可以直接获取环境配置中的占位符属性(基于SPEL的特性,甚至可以支持嵌套占位符)。解析成功的所有任务实例存放在ScheduledAnnotationBeanPostProcessor的一个映射scheduledTasks中
finishRegistration
解析和缓存工作完成之后,接着分析最终激活所有调度任务的逻辑,见互斥方法afterSingletonsInstantiated()和onApplicationEvent(),两者中一定只有一个方法能够调用finishRegistration()
Expand/Collapse Code Block
| |
注意: 1、SchedulingConfigurer是调度模块提供给使用的进行扩展的钩子接口,用于在激活所有调度任务之前回调ScheduledTaskRegistrar实例,只要拿到ScheduledTaskRegistrar实例,我们就可以使用它注册和装载新的Task。
ScheduledTaskRegistrar
Expand/Collapse Code Block
| |
注意: 1、如果没有配置TaskScheduler或者ScheduledExecutorService类型的Bean,那么调度模块只会创建一个线程(默认执行器 ScheduledExecutorService)去调度所有装载完毕的任务,如果任务比较多,执行密度比较大,很有可能会造成大量任务饥饿,表现为存在部分任务不会触发调度的场景(这个是调度模块生产中经常遇到的故障,需要重点排查是否没有设置TaskScheduler或者ScheduledExecutorService,或者设置了单默认线程数据为1)。
schedule
这里使用schedule作为例子分析,其它类似方法大同小异,默认 ConcurrentTaskScheduler 类
Expand/Collapse Code Block
| |
ReschedulingRunnable
Expand/Collapse Code Block
| |
Reference
5.5.2.3 - 12.Spring扩展
Spring启动流程
Bean作为Spring Framework的核心,其生命周期可以分为五个主干流程:
1、容器启动阶段(严格来讲这个不属于Bean的生命周期)
2、Bean(单例非懒加载)的实例化阶段
3、Bean的属性注入阶段
4、Bean的初始化阶段
5、Bean的销毁阶段
Spring容器启动流程

扩展接口启动调用顺序图

ApplicationContextInitializer
| |
功能
spring容器在刷新之前初始化 ConfigurableApplicationContext 的回调接口,简单来说,就是在容器刷新之前调用此类的 initialize 方法。 用户可以在整个spring容器还没被初始化之前做一些事情。
使用场景:
在最开始激活一些配置,或者利用这时候class还没被类加载器加载的时机,进行动态字节码注入等操作。
代码示例
| |
因为这时候spring容器还没被初始化,所以想要自己的扩展的生效,有以下三种方式: 1、在启动类中用加入
| |
2、配置文件配置
| |
3、Spring SPI扩展,在spring.factories中加入
| |
BeanDefinitionRegistryPostProcessor
功能
该接口在读取项目中的beanDefinition之后执行,提供一个补充的扩展点
使用场景:你可以在这里动态注册自己的beanDefinition,可以加载classpath之外的bean
代码示例
| |
BeanFactoryPostProcessor
功能
该接口是beanFactory的扩展接口,调用时机在spring在读取beanDefinition信息之后,实例化bean之前。
用户可以通过实现这个扩展接口来自行处理一些东西,比如修改已经注册的beanDefinition的元信息。
代码示例
| |
InstantiationAwareBeanPostProcessor
功能
该接口继承了BeanPostProcess接口,区别如下 :
BeanPostProcess接口只在bean的初始化阶段进行扩展(注入spring上下文前后),而InstantiationAwareBeanPostProcessor接口在此基础上增加了3个方法,把可扩展的范围增加了实例化阶段和属性注入阶段。
该类主要的扩展点有以下5个方法,主要在bean生命周期的两大阶段:实例化阶段和初始化阶段,下面一起进行说明,按调用顺序为:
1、postProcessBeforeInstantiation:实例化bean之前,相当于new这个bean之前
2、postProcessAfterInstantiation:实例化bean之后,相当于new这个bean之后
3、postProcessPropertyValues:bean已经实例化完成,在属性注入时阶段触发@Autowired, @Resource等注解原理基于此方法实现
4、postProcessBeforeInitialization:初始化bean之前,相当于把bean注入spring上下文之前
5、postProcessAfterInitialization:初始化bean之后,相当于把bean注入spring上下文之后
使用场景:
无论是写中间件和业务,都能利用这个特性。比如对实现了某一类接口的bean在各个生命期间进行收集,或者对某个类型的bean进行统一的设值等等。
代码
Expand/Collapse Code Block
| |
SmartInstantiationAwareBeanPostProcessor
功能
该扩展接口有3个触发点方法
1、predictBeanType
该触发点发生在postProcessBeforeInstantiation之前(在图上并没有标明,因为一般不太需要扩展这个点),这个方法用于预测Bean的类型,返回第一个预测成功的Class类型,如果不能预测返回null;当你调用BeanFactory.getType(name)时当通过bean的名字无法得到bean类型信息时就调用该回调方法来决定类型信息。
2、determineCandidateConstructors
该触发点发生在postProcessBeforeInstantiation之后,用于确定该bean的构造函数之用,返回的是该bean的所有构造函数列表。用户可以扩展这个点,来自定义选择相应的构造器来实例化这个bean。
3、getEarlyBeanReference
该触发点发生在postProcessAfterInstantiation之后,当有循环依赖的场景,当bean实例化好之后,为了防止有循环依赖,会提前暴露回调方法,用于bean实例化的后置处理。这个方法就是在提前暴露的回调方法中触发。
代码示例
| |
BeanFactoryAware
功能
该类只有一个触发点,发生在bean的实例化之后,注入属性之前,也就是Setter之前。这个类的扩展点方法为 setBeanFactory,可以拿到BeanFactory这个属性。
使用场景:
可以在bean实例化之后,但还未初始化之前,拿到 BeanFactory,然后可以对每个bean作特殊化的定制。也或者可以把BeanFactory拿到进行缓存,日后使用。
代码示例
| |
ApplicationContextAwareProcessor
EnvironmentAware
功能
用于获取EnviromentAware的一个扩展类,这个变量非常有用, 可以获得系统内的所有参数。当然个人认为这个Aware没必要去扩展,因为spring内部都可以通过注入的方式来直接获得。
EmbeddedValueResolverAware
功能
用于获取StringValueResolver的一个扩展类, StringValueResolver用于获取基于String类型的properties的变量,一般我们都用@Value的方式去获取,如果实现了这个Aware接口,把StringValueResolver缓存起来,通过这个类去获取String类型的变量,效果是一样的。
ResourceLoaderAware
功能
用于获取ResourceLoader的一个扩展类,ResourceLoader可以用于获取classpath内所有的资源对象,可以扩展此类来拿到ResourceLoader对象。
ApplicationEventPublisherAware
功能
用于获取ApplicationEventPublisher的一个扩展类,ApplicationEventPublisher可以用来发布事件,结合ApplicationListener来共同使用,下文在介绍ApplicationListener时会详细提到。这个对象也可以通过spring注入的方式来获得。
MessageSourceAware
功能
用于获取MessageSource的一个扩展类,MessageSource主要用来做国际化。
ApplicationContextAware
功能
实现该接口可获取到 ApplicationContext,ApplicationContext应该是很多人非常熟悉的一个类了,就是spring上下文管理器,可以手动的获取任何在spring上下文注册的bean,我们经常扩展这个接口来缓存spring上下文,包装成静态方法。同时ApplicationContext也实现了BeanFactory,MessageSource,ApplicationEventPublisher等接口,也可以用来做相关接口的事情。
代码示例
| |
原理
由 ApplicationContextAwareProcessor 实现,在bean对象初始化之前判断bean是否是ApplicationContextAware类型
具体方法见:postProcessBeforeInitialization
| |
该方法会调用 invokeAwareInterfaces 方法注入属性。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void invokeAwareInterfaces(Object bean) {
if (bean instanceof Aware) {
if (bean instanceof EnvironmentAware) {
((EnvironmentAware)bean).setEnvironment(this.applicationContext.getEnvironment());
}
if (bean instanceof EmbeddedValueResolverAware) {
((EmbeddedValueResolverAware)bean).setEmbeddedValueResolver(this.embeddedValueResolver);
}
if (bean instanceof ResourceLoaderAware) {
((ResourceLoaderAware)bean).setResourceLoader(this.applicationContext);
}
if (bean instanceof ApplicationEventPublisherAware) {
((ApplicationEventPublisherAware)bean).setApplicationEventPublisher(this.applicationContext);
}
if (bean instanceof MessageSourceAware) {
((MessageSourceAware)bean).setMessageSource(this.applicationContext);
}
if (bean instanceof ApplicationContextAware) {
((ApplicationContextAware)bean).setApplicationContext(this.applicationContext);
}
}
}
BeanNameAware
功能
该类也是Aware扩展的一种,触发点在bean的初始化之前,也就是postProcessBeforeInitialization之前,触发点方法只有一个:setBeanName
使用场景:
用户可以扩展这个点,在初始化bean之前拿到spring容器中注册的的beanName,自行修改这个beanName的值。
代码示例
| |
@PostConstruct
功能
作用在bean的初始化阶段,如果对一个方法使用了@PostConstruct,会先调用这个方法。触发点是在postProcessBeforeInitialization之后,InitializingBean.afterPropertiesSet之前。
使用场景:
用户可以对某一方法进行标注,来进行初始化某一个属性
代码示例
| |
InitializingBean
功能
用来初始化bean。InitializingBean接口为bean提供了初始化方法的方式,它只包括afterPropertiesSet方法,凡是继承该接口的类,在初始化bean的时候都会执行该方法。这个扩展点的触发时机在postProcessAfterInitialization之前。
使用场景:
用户实现此接口,来进行系统启动的时候一些业务指标的初始化工作。
代码示例
| |
FactoryBean
功能
创建FactoryBean
一般情况下,Spring通过反射机制利用bean的class属性指定支线类去实例化bean,在某些情况下,实例化Bean过程比较复杂,如果按照传统的方式,则需要在bean中提供大量的配置信息。配置方式的灵活性是受限的,这时采用编码的方式可能会得到一个简单的方案。Spring为此提供了一个org.springframework.bean.factory.FactoryBean的工厂类接口,用户可以通过实现该接口定制实例化Bean的逻辑。FactoryBean接口对于Spring框架来说占用重要的地位,Spring自身就提供了70多个FactoryBean的实现。它们隐藏了实例化一些复杂bean的细节,给上层应用带来了便利。从Spring3.0开始,FactoryBean开始支持泛型,即接口声明改为FactoryBean
使用场景:
用户可以扩展这个类,来为要实例化的bean作一个代理,比如为该对象的所有的方法作一个拦截,在调用前后输出一行log,模仿ProxyFactoryBean的功能。
代码示例
| |
原理
参考spring bean的加载过程,FactoryBean获取到的实际是getObject方法得到的对象,如果要获取真实的FactoryBean对象,需要在bean的id加个"&"前缀,如"&carFactoryBean"。
SmartInitializingSingleton
功能
这个接口中只有一个方法afterSingletonsInstantiated,其作用是是 在spring容器管理的所有单例对象(非懒加载对象)初始化完成之后调用的回调接口。其触发时机为postProcessAfterInitialization之后。
使用场景:
用户可以扩展此接口在对所有单例对象初始化完毕后,做一些后置的业务处理。
代码示例
| |
CommandLineRunner
功能
接口只有一个方法:run(String... args),触发时机为整个项目启动完毕后,自动执行。如果有多个CommandLineRunner,可以利用@Order来进行排序。
使用场景:
用户扩展此接口,进行启动项目之后一些业务的预处理。
代码示例
| |
DisposableBean
功能
扩展点也只有一个方法:destroy(),其触发时机为当此对象销毁时,会自动执行这个方法。比如说运行applicationContext.registerShutdownHook时,就会触发这个方法。
代码示例
| |
ApplicationListener
功能
ApplicationListener可以监听某个事件的event,触发时机可以穿插在业务方法执行过程中,用户可以自定义某个业务事件。但是spring内部也有一些内置事件,可以穿插在启动调用中。可以利用这个特性,来自己做一些内置事件的监听器来达到和前面一些触发点大致相同的事情。
spring主要的内置事件:
1、ContextRefreshedEventApplicationContext
被初始化或刷新时,该事件被发布。这也可以在 ConfigurableApplicationContext接口中使用 refresh() 方法来发生。此处的初始化是指:所有的Bean被成功装载,后处理Bean被检测并激活,所有Singleton Bean 被预实例化,ApplicationContext容器已就绪可用。
2、ContextStartedEvent
使用 ConfigurableApplicationContext (ApplicationContext子接口)接口中的 start() 方法启动 ApplicationContext 时,该事件被发布。你可以调查你的数据库,或者你可以在接受到这个事件后重启任何停止的应用程序。
3、ContextStoppedEvent
使用 ConfigurableApplicationContext 接口中的 stop() 停止 ApplicationContext 时,发布这个事件。你可以在接受到这个事件后做必要的清理的工作
4、ContextClosedEvent
使用 ConfigurableApplicationContext接口中的 close()方法关闭 ApplicationContext 时,该事件被发布。一个已关闭的上下文到达生命周期末端;它不能被刷新或重启
5、RequestHandledEvent
这是一个 web-specific 事件,告诉所有 bean HTTP 请求已经被服务。只能应用于使用DispatcherServlet的Web应用。在使用Spring作为前端的MVC控制器时,当Spring处理用户请求结束后,系统会自动触发该事件
Reference
https://www.jianshu.com/p/397c15cbf34a
https://luyu05.github.io/2019/06/26/SpringFrame-Extension-Point/
5.5.2.4 - 50.Spring循环依赖
本文主要讲解Spring循环依赖的缓存及其作用
三级缓存
一级缓存
Map<String, Object> singletonObjects = new ConCurrentHashMap<>(256)
用于保存BeanName和创建bean实例之间的关系**(成品对象)**
三级缓存
Map<String, ObjectFactory> singletonFactories = new HashMap<>(16)
用于保存BeanName和创建bean的工厂之间的关系**(lambda表达式,完成代理对象的覆盖过程)**
ObjectFactory是一个函数式接口,仅有一个方法,可以传入lambda表达式,可以是匿名内部类,通过调用getObject方法来执行具体的逻辑
二级缓存
Map<String, Object> earlySingletonObjects = new ConCurrentHashMap<>(16)
保存BeanName和创建bean实例之间的关系,与singletonFactories的不同之处在于,当一个单例bean被放到这里之后,那么当bean还在就可以通过getBean方法获取到,可以方便进行循环依赖的检测**(半成品对象)**
set:可以解决
构造方法:没有办法解决
实例化和初始化分开处理,提前暴露对象
spring每次创建对象时都是先从容器中查找,找不到再创建
finishBeanFactoryInitialization(beanFactory)
beanFacatory.preInstantialteSingleton()
getBean() -> doGetBean() -> createBean() -> doCreateBean()
每次先按照顺序从一、二、三级缓存中寻找bean,当一级或二级中找到时,便移除二三级中的缓存(一级中存在时二三级都移除,二级存在时移除三级)。
问题
1、三级缓存解决循坏依赖问题的关键是什么?为什么通过提前暴露对象能解决?
实例化和初始化分开操作,在中间过中给其他对象赋值的时候,并不是一个完整对象,而是把半成品对象赋值给了其他对象
2、如果只使用一级缓存能不能解决问题?
不能。在整个处理过程中,缓存中存放的是半成品的和成品对象,如果只有一级缓存,那么成品和半成品都会放到一级缓存中,有可能在获取过程中获取到半成品对象,此时半成品对象是无法使用的,不能直接进行相关的处理,因此要把半成品和成品和成品的存放空间分割开来。
3、只使用二级缓存行不行?为什么需要三级缓存?
如果我能保证所有的bean对象都不去调用getEarlyBeanReference方法,使用二级缓存可以吗?

是的,如果保证所有的bean对象都不调用此方法,就可以只使用二级缓存!
使用三级缓存的本质在于使用AOP代理问题!!
4、如果某个bean对象代理对象,那么会不会创建普通的bean对象?
会!
5、为什么使用了三级缓存就可以解决AOP代理问题?
当一个对象需要被代理的时候,在整个过程中包含两个对象。一个是普通对象,一个是代理生成的对象,bean默认都是单例,那么我们在整个生命周期的处理环节中,一个beanname能对应两个对象吗?不能,既然不能,保证我们在使用的时候加一层判断,判断一下是否需要进行代理的处理。
6、怎么知道什么时候使用呢?
因为不知道什么时候会调用,所以通过一个匿名内部类的方式,在使用的时候直接对原对象进行覆盖操作,保证全局唯一!
7、为什么要包一层ObjectFactory对象?
如果创建的bean有对应的代理(前提),那么其他对象注入时,注入的应该是对应的代理对象;但是Spring无法提前知道这个对象是否有循环依赖的情况,而正常情况下(没有循环依赖),Spring都是在创建好完成品bean之后才创建对应的代理。这时候Spring有两个选择:
1)不管有没有循坏依赖,都提前创建好代理对象,并将代理对象放入缓存,出现循环依赖时,其他对象直接就可以取到代理对象并注入。
2)不提前创建好代理对象,在出现循坏依赖被其他对象注入时,才实时生成代理对象。这样在没有循环依赖的情况下,bean就可以按着Spring设计原则的步骤来创建。
Spring选择了第二种方式,那怎么做到提前曝光对象而又不生成代理呢?
Spring就是在对象外面包一层ObjectFactory,提前曝光的是ObjectFactory对象,在被注入时才在ObjectFactory.getObject方式内实时生成代理对象,并将生成好的代理对象放入到第二级缓存中。
为了防止对象在后面的初始化时重复代理,在创建代理时,第二级缓存会记录已代理的对象。
如果Spring选择了上面第一种方式,就会有以下不同的处理逻辑:
1、在提前曝光半成品时,直接执行getEarlyBeanReference创建到代理,并放入到二级缓存中。
2、有了上一步,就不需要通过ObjectFactory来延迟执行getEarlyBeanReference,也就不需要singletonFactory这一级缓存。
至于为什么不适用第一种方式在bean构造完之后就创建代理对象,是因为这违背了Spring的设计原则。Spring结合AOP跟bean的生命周期,是在bean创建完全之后通过AnnotationAwareAspectJAutoProxyCreator这个后置处理器来完成的,在这个后置处理的postProcessAfterInitialization方法中对初始化后的bean完成AOP代理。如果出现了循环依赖,那没有办法,只有给bean先创建代理,但是没有出现循环依赖的情况下,设计之初就是让bean在生命周期的最后一步完成代理而不是在实例化后(未初始化)就立马完成代理。
构造器依赖注入
解决方法及原理探索,参考:https://www.jianshu.com/p/a178d84d2bcb
使用@lazy注解,代理。
构造器注入源码分析,参考:https://cloud.tencent.com/developer/article/1461797
https://www.jianshu.com/p/885c2389b0e4
Reference
5.5.2.5 - 51.SpringBean的加载源码
本文主要讲解Spring Bean的加载
Spring源码阅读技巧
1、Spring源码中真正干活的都是以do开头的方法
2、只找有正确返回结果的代码
3、条件断点
4、发挥想象分析、猜
测试代码
resources/applicationContext.xml
| |
BeanTest.java
| |
Bean的加载
参考Spring源码书P79
整体流程图



bean的获取
| |
获取bean的bean的过程:
1、转换对应的beanName
传入的参数name不一定是beanName,可能是别名,也可能是FactoryBean,需要进一步转换
| |
- 去除FactoryBean的修饰符,也就是如果name="&aa",那么首先去除&而使name="aa"
- 将别名alias转成最终的beanName
2、尝试从缓存中加载单例
单例在同一个容器中只会创建一次,先尝试加载单例,如果加载不成功则再次尝试从singletonFactories中加载。
| |
因为在创建单例bean的时候会存在依赖注入的情况,而在创建依赖的时候为了避免循环依赖,在Spring中创建bean的原则是不等bean创建完成就会将创建bean的ObjectFactory提早曝光加入到缓存中,一旦下一个bean创建时候需要依赖上一个bean则直接使用ObjectFactory(详细见循环依赖)
3、bean的实例化
如果从缓存中得到了bean的原始状态,则需要对bean进行实例化。
强调:缓存中只是原始的bean状态,不一定是最终想要的bean。如需要对工厂bean进行处理,那么这里得到的其实是工厂bean的初始状态,但我们真正需要的是工厂bean中定义的factory-method方法中返回的bean,而getObjectBeanInstance就是完成这个工作的
4、原型模式的依赖检查
只有在单例的情况下才会尝试解决循坏依赖
| |
5、检测parentBeanFactory
!containsBeanDefinition检测如果当前加载的XML配置文件中不包含beanName所对应的配置,就只能到parentBeanFactory尝试下,然后递归地调用getBean方法。
| |
6、转换Definition
将存储XML配置文件的GernericBeanDifinition转换为RootBeanDifinition。
因为从XML配置文件中读取到的Bean信息是存储在GernericBeanDifinition中的,但是所有的Bean后续处理都是针对于RootBeanDefinition的,所以需要进行转换。转换的同时如果父类bean不为空的话,则会一并合并父类的属性。
| |
7、寻找依赖
因为bean的初始化过程中很可能会用到某些属性,某些属性很可能是动态配置的,并且配置成依赖于其他的bean,那么这个时候有必要先加载依赖的bean。
| |
8、针对不同的scope进行bean的创建
Spring默认的scope是singleton,其他如prototype、request。该步骤根据不同的配置进行不同的初始化策略。
| |
9、类型转换
该功能是将返回的bean转换为requiredType指定的类型。如将String转换成Integer,或者其他的转换器。用户也可以自己扩展转换器。
| |
1、FactoryBean的使用
FactoryBean接口对于Spring框架来说占有重要的地位,Spring自身提供了70多个FactoryBean的实现。它们隐藏了实例化一些复杂的bean的细节,给上层应用带来了便利。
当配置文件中
举个例子,使用逗号分隔将多个属性值配置在一个参数中,这种自定义的配置方式比较灵活。详细可见P84。
如果需要获取到XXFactoryBean的实例,则需要在使用getBean(beanName)方法时在beanName前显式地加上"&"前缀,例如getBean("&car")
2、缓存中获取单例bean
因为在创建单例bean的过程中会存在依赖注入的情况,而在创建依赖的时候为了避免循坏依赖,Spring创建bean的原则是不等bean创建完成就会将创建bean的ObjectFactory提早曝光加入到缓存中,一旦下一个bean创建时需要依赖上个bean,则直接使用ObjectFactory。
Expand/Collapse Code Block
| |
简单解释存储bean的不同map 1、singletonObjects:用于保存BeanName和创建bean实例之间的关系,bean name -> bean instance
2、singletonFactories:用于保存BeanName和创建bean的工厂之间的关系,bean name -> ObjectFactory
3、earlySingletonObjects:也是保存beanName和创建bean实例之间的关系,只不过这里的bean相当于半成品,其目的是用来检测循环引用。
4、registeredSingletons:用来保存当前所有已注册的bean。
3、从bean的实例中获取对象
在getBean方法中,getObjectForBeanInstance是个高频使用的方法,无论是从缓存中获得bean还是根据不同的scope策略加载bean,总之我们得到bean的实例后要做的第一步就是调用该方法检测正确性,其实就是用于检测当前bean是否是FactoryBean类型的bean,如果是,则调用该bean对应FactoryBean实例中的getObject()作为返回值。
无论是从缓存中获取到的bean还是通过不同的scope策略加载的bean都只是最原始的bean状态,并不一定是我们最终想要的bean。举个例子,假如我们需要对工厂bean进行处理,那么这里得到的其实是工厂bean的初始状态,但是我们真正需要的是工厂bean中定义的factory-method方法中返回的bean,而getObjectForBeanInstance方法就是完成这个工作的。
Expand/Collapse Code Block
| |
上述代码大多是辅助代码以及一些功能性的判断,真正的核心代码委托给了getObjectFromFactoryBean。 上述代码主要的工作:
1)对FactoryBean正确性的验证
2)对非FactoryBean不做任何处理
3)对bean进行转换
4)将从Factory中解析bean的工作委托给getObjectFromFactoryBean
getObjectFromFactoryBean方法只做了一件事,即返回的bean如果是单例的,就必须保证全局唯一,同时,如果是单例,不必重复创建,可以使用缓存来提高性能。
Expand/Collapse Code Block
| |
注意后置处理代码在不同的spring版本中位置不一样,有的放在getObjectFromFactoryBean中,有的放在doGetObjectFromFactoryBean中
doGetObjectFromFactoryBean方法中就是最终的方法,即factory.getObject()。
Expand/Collapse Code Block
| |
可以看到获取到bean之后,没有立即返回,而是接下来又进行了后处理的操作postProcessObjectFromFactoryBean 后处理规则:尽可能保证所有bean初始化后都会调用注册的BeanPostProcessor的postProcessAfterInitialization方法进行处理,在实际开发过程中大可以针对此特性设计自己的业务逻辑。
| |
注意:这里找代码涉及到子类方法的覆盖
4、获取单例
如果缓存中不存在已经加载过的单例bean就需要从头开始bean的加载过程了,而spring中使用getSingleton的重载方法实现bean的加载过程。
调用入口:
| |
getSingleton重载方法Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(beanName, "'beanName' must not be null");
// 全局变量需要同步
synchronized (this.singletonObjects) {
// 首先检查对应的bean是否已经加载过,因为singleton模式其实就是复用以创建的bean
Object singletonObject = this.singletonObjects.get(beanName);
// 如果为空才可以进行singleto的bean的初始化
if (singletonObject == null) {
if (this.singletonsCurrentlyInDestruction) {
throw new BeanCreationNotAllowedException(beanName,
"Singleton bean creation not allowed while the singletons of this factory are in destruction " +
"(Do not request a bean from a BeanFactory in a destroy method implementation!)");
}
if (logger.isDebugEnabled()) {
logger.debug("Creating shared instance of singleton bean '" + beanName + "'");
}
// 前处理
beforeSingletonCreation(beanName);
boolean newSingleton = false;
boolean recordSuppressedExceptions = (this.suppressedExceptions == null);
if (recordSuppressedExceptions) {
this.suppressedExceptions = new LinkedHashSet<Exception>();
}
try {
// 初始化bean
singletonObject = singletonFactory.getObject();
newSingleton = true;
}
catch (IllegalStateException ex) {
// Has the singleton object implicitly appeared in the meantime ->
// if yes, proceed with it since the exception indicates that state.
singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
throw ex;
}
}
catch (BeanCreationException ex) {
if (recordSuppressedExceptions) {
for (Exception suppressedException : this.suppressedExceptions) {
ex.addRelatedCause(suppressedException);
}
}
throw ex;
}
finally {
if (recordSuppressedExceptions) {
this.suppressedExceptions = null;
}
// 后处理
afterSingletonCreation(beanName);
}
if (newSingleton) {
// 加入缓存
addSingleton(beanName, singletonObject);
}
}
return (singletonObject != NULL_OBJECT ? singletonObject : null);
}
}
上述代码使用了回调方法,使得程序可以在单例创建的前后做一些准备及处理操作,而真正的获取单例bean的方法其实并不是在此方法中实现的,其实现逻辑是在ObjectFactory类型的实例singletonFactory中实现的。而这些准备及处理操作包括如下内容: 1)检查缓存是否已经加载过
2)若没有加载,则记录beanName的正在加载状态
3)加载单例前记录状态
beforeSingletonCreation不是空实现,做了很重要的操作:记录加载状态,即通过singletonsCurrentlyInCreation.add(beanName)将当前正要创建的bean记录在缓存中,这样便可以对循环依赖进行检测。
| |
4)通过调用参数传入的ObjectFactory的个体Object方法实例化bean 5)加载单例后的处理方法调用
与步骤3)相似,当bean加载结束后需要移除缓存中对该bean的正在加载状态的记录
6)将结果记录至缓存并删除加载bean过程中所记录的各种辅助状态
| |
7)返回处理结果 代码见上面的获取单例的调用入口getSingleton
5、准备创建bean
即上述getSingleton中调用createBean方法
注意源码溯源方法的重载覆盖
Expand/Collapse Code Block
| |
处理override属性
| |
实例化的前置处理
当经过前置处理后返回的结如果不为空,那么会直接略过后序的Bean的创建而直接返回结果。这一特性起着至关重要的作用,AOP功能是基于这里判断的
| |
6、循环依赖
7、创建bean
createBean调用核心代码doCreateBean
整个函数的概要思路:
1)如果是单例需要首先清楚缓存
2)实例化bean,将BeanDefinition转换为BeanWrapper
3)MergedBeanDefinitionPostProcessor的应用
bean合并后的处理,Autowired注解正是通过此方法实现诸如类型的预解析
4)依赖处理
5)属性填充
6)循环依赖处理
7)注册DisposableBean
如果配置了destroy-method,需要注册以便于在销毁时候调用
8)完成创建并返回
Expand/Collapse Code Block
| |
创建bean的实例
实例化逻辑:
1)如果在RootBeanDefinition中存在factoryMethodName属性,或者在配置文件中配置了factory-method,那么Spring会尝试使用instantiateUsingFactoryMethod(beanName, mbd, args)方法根据RootBeanDefinition中的配置生成bean的实例
2)解析构造函数并进行构造函数的实例化。因为一个bean对应的类中可能会有多个构造函数,而每个构造函数的参数不同,Spring在根据参数及类型去判断最终会使用哪个构造函数进行实例化。但是,判断的过程是个比较消耗性能的步骤,所以采用缓存机制,如果已经解析过则不需要重复解析而是直接从RootBeanDefinition中的属性resolvedConstructorOrFacrotyMethod缓存的值去取,否则需要再次解析,并将解析的结果添加至RootBeanDefinition中的属性resolvedConstructorOrFactoryMethod中。
Expand/Collapse Code Block
| |
1、autowireConstructor
对于实例的创建Spring分成了两种情况,一种是通用的实例化,另一种是带有参数的实例化。带有参数的实例化过程相当复杂,因为存在着不确定性,所以在判断对应参数上做了大量工作。
这里代码逻辑太长,略,见P105
1)构造函数参数的确定
- 根据explicitArgs参数判断
- 缓存中获取
- 配置文件中获取 2)构造函数的确定
3)根据确定的构造函数转换对应的参数类型
4)构造函数不确定性的验证
5)根据实例化策略以及得到的构造函数及构造函数参数实例化Bean。
2、instantiateBean
不带参数的构造函数的实例化过程
Expand/Collapse Code Block
| |
3、实例化策略
经过前面的分析,已经得到了足以实例化的所有相关信息,完全可以使用最简单的反射方法直接反射来构造实例对象,但Spring却没有这么做
Expand/Collapse Code Block
| |
首先判断如果beanDefinition.getMethodOverrides()为空也就是用户没有使用replace或lookup的配置方法,那么直接使用反射的方式,简单快捷,但是如果使用了这两个特性,就直接使用反射的方式创建实例就不妥了,因为需要将这两个配置提供的功能切入进去,所以就必须使用动态代理的方式将包含两个特性对应的逻辑的拦截增强设置进去,这样百能保证在调用方法的时候会被相应的拦截器增强,返回值为包含拦截器的代理实例。
记录创建bean的ObjectFactory
在doCreate函数中有这样一段代码
| |
属性注入
| |
autowireByName
autowireByType
autowireByType与autowireByName对于我们理解与使用来说复杂程度都很相似,但是其实现功能的复杂度却完全不一样。
applyPropertyValues
初始化bean
| |
注册DisposableBean
除了熟知的配置属性destroy-method方法外,用户还可以注册后处理器DestructionAwareBeanPostProcessor来统一处理bean的销毁方法
容器的功能扩展
BeanFactory接口以及它的默认实现类XmlBeanFactory,Spring还提供了另一个接口ApplicationContext,用于扩展BeanFactory的现有功能。
ApplicationContext和BeanFactory都是用于加载bean的,相比之下,ApplicationContext提供了更多的扩展功能,包含BeanFactory的所有功能,通常建议比BeanFactory优先(除非在一些限制的场合,如字节长度对内存有很大的影响时(Applet))。
| |
Spring的扩展
prepareRefresh
iniPropertySources()
留给子类覆盖,初始化属性资源
getEnvironment().validateRequiredProperties()
创建并获取环境对象,验证需要的属性文件是否都已经放入环境中
obtainFreshBeanFactory
加载xml配置文件的属性值到当前工厂中,最重要的就是BeanDefinition
扩展实现customizeBeanFactory方法
此方法是用来实现BeanFactory的属性设置,主要是设置两个属性:
allowBeanDefinitionOverriding:是否允许覆盖同名称的不同定义的对象
allowCircularReferences:是否允许bean之间的循环依赖
| |
适配器模式

debug时调用toString()方法初始化加载文件

xml配置文件头信息
| |
bean覆盖问题
5.5.2.6 - spring注解驱动+源码
组件注册
给容器中注入组件
1)包扫描+组件标注注解
(@Controller/@Service/@Repository/@Component)
2)@Bean导入
[导入的第三方包里面的组件]
3)@Import
[快速给容器中导入一个组件],查看源码,可以看到有三种导入方法
3.1)@Import
(要导入到容器中的组件):容器找那个就会自动注册这个组件,id默认是全类名
3.2)ImportSelector
返回需要导入的全类名数组(注意:可以返回空数组,但不要返回null)
3.3)ImportBeanDefinitionRegistrar
注:在Config的类上注,如下
Expand/Collapse Code Block
| |
4)使用FactoryBean(工厂Bean)
4.1)默认获取到的是工厂bean调用getObject创建的对象
4.2)要获取工厂Bean本身,需要在id前面加一个&(查看FactoryBean接口源码,定义了常量)
Expand/Collapse Code Block
| |
生命周期
指定初始化和销毁方法
1、指定初始化方法和销毁方法
| |
2、通过让Bean实现InitializingBean(定义初始化逻辑) 、DisposableBean(定义销毁逻辑) 3、可以使用JSR250,在bean定义的方法上加上以下注解 @PostConstruct:bean创建完成 @PreDestroy:容易移除bean之前
4、BeanPostProcessor接口,bean的后置处理器;在bean初始化前后进行一些处理工作; postProcessBeforeInitialization(Object object, xxx); postProcessAfterInittialization(Object object, xxx);
InstantiationAwareBeanPostProcessor
其方法postProcessAfterInstantiation,可以根本Object bean判断需要实例话的方法,或者全部不实例化
AOP
@EnableAspectJAutoProxy
1、@Import(AspectJAutoProxyRegistrar.class 给容器中导入AspectJAutoProxyRegistrar,
利用AspectJAutoProxyRegistrar自定义给容器中注册bean。
internalAutoProxyCreator = AnnotationAwareAspectJAutoProxyCreator
给容器中注册一个AnnotationAwareAspectJAutoProxyCreator
2、AnnotationAwareAspectJAutoProxyCreator
类的继承关系
| |
类的主要方法
| |
调用流程
1、传入配置类,创建ioc容器
2、注册配置类,调用refresh()刷新容器
3、registerBeanPostProcessors(beanFactory);注册bean的后置处理器来拦截bean的创建
3.1、先获取ioc容器已经定义了的需要创建对象的所有BeanPostProcessor
3.2、给容器中加别的BeanPostProcessor
3.3、优先注册实现了PriorityOrdered接口的BeanPostProcessor
3.4、再给容器中注册实现了Ordered接口的BeanPostProcessor
3.5、没实现优先级接口的BeanPostProcessor
3.6、注册BeanPostProcessor,实际上就是创建BeanPostProcessor对象,保存在容器中。
创建internalAutoProxyCreator的BeanPostProcessor【AnnotationAwareAspectJAutoProxyCreator】
3.6.1、创建bean的实例
3.6.2、populateBean:给bean各种属性赋值
3.6.3、initializeBean:初始化bean
3.6.3.1、invokeAwareMethods():处理Aware接口的方法回调
3.6.3.2、applyBeanPostProcessorsBeforeInitialization():应用后置处理器的BeforeInitialization
3.6.3.3、invokeInitMethods():执行自定义的初始化方法
3.6.3.4、applyBeanPostProcessorsAfterInitialization():执行后置处理器的postProcessAfterInitialization()
3.6.4、BeanPostProcessor(AnnotationAwareAspectJAutoProxyCreator)
3.7、把BeanPostProcessor注册到BeanFactory中:
beanFactory.addBeanPostProcessor(postProcessor);
===以上是创建和注册AnnotationAwareAspectJAutoProxyCreator的过程===
4、finishBeanFactoryInitialization(beanFactory);完成BeanFactory初始话工作,创建剩下的单实例bean
4.1、编译获取容器中所有的bean,依次创建对象getBean(beanName);
getBean->doGetBean()->getSingleton()
4.2、创建bean
【AnnotationAwareAspectJAutoProxyCreator在所有bean创建之前会有一个拦截,InstantiationAwareBeanPostProcessor,会调用postProcessBeforeInstantiation()】
4.2.1、先从缓存中获取当前bean,如果能获取到,说明bean是之前被创建过直接使用,否则再创建;只要创建好的bean都会被缓存起来
4.2.2、createBean(); 创建bean; AnnotationAwareAspectJAutoProxyCreator会在任何bean创建之前尝试返回bean的实例
【BeanPostProcessor是在Bean对象创建完成初始化前后调用的】
【InstantiationAwareBeanPostProcessor是在创建Bean实例之前先尝试用后置处理器返回对象的】
4.2.2.1、resolveBeforeInstantiation(beanName, mbdToUse); 解析BeforeInstantiation
希望后置处理器在次能返回一个代理对象,如果不能就继续
4.2.2.1.1、后置处理器先尝试返回对象;
bean=applyBeanPostProcessorsBeforeInstantiation()
拿到所有后置处理器,如果是InstanttiationAwareBeanPostProcessor,就执行postProcessBeforeInstantiation
if (bean != null) {
bean = applyBeanPostProcessorsAfterInitialization(bean, beanName);
}
4.2.2.2、doCreateBean(beanName, mbdToUse, args); 真正地去创建一个bean实例;和3.6流程一致
processor的作用
AnnotationAwareAspectJAutoProxyCreator【InstanttiationAwareBeanPostProcessor】的作用:
1、每一个bean创建之前,调用postProcessBeforeInstantiation();
关心MathCalculator和LogAspect的创建
1.1、判断当前bean是否在advisedBeans中(保存了所有需要增强bean)
1.2、判断当前bean是否是基本类型Advice/PointCut/Adivsor/AopInfrastructureBean,或者是否为切面(@Aspect)
1.3、是否需要跳过
1.3.1、获取候选的增强器(切面里面的通知方法)【List
每一个封装的通知方法的增强器是InstantiationModelAwarePointcutAdvisor;
判断每一个增强器是否是AspectJPointcutAdvisor类型的,返回true
1.3.2、永远返回false
2、创建对象
postProcessAfterInitialization;
return wrapIfNecessary(bean, beanName, cacheKey);//包装如果需要的情况下
2.1、获取当前bean的所有增强器(通知方法)Object[] specificInterceptors
2.1.1、找到能在当前bean使用的增强器(找哪些通知方法是需要切入当前bean方法的)
2.1.2、获取到能在bean使用的增强器。
2.1.3、给增强排序
2.2、保存当前bean在advisedBeans中
2.3、如果当前bean需要增强,创建当前bean的代理对象
2.3.1、获取所有增强器(通知方法)
2.3.2、保存到proxyFactory
2.3.3、创建代理对象,spring自动决定(jdk、cglib)
2.4、给容器中返回当前组件使用cglib增强了的代理对象
2.5、以后容器中获取到的就是这个组件的代理对象,执行目标方法的时候,代理对象就会执行通知方法的流程
3、目标方法的执行
容器中保存了组件的代理对象(cglib增强后的对象),这个对象里面保存了详细信息(比如增强器,目标对象,xxx);
3.1、CglibAopProxy.intercept(); 拦截目标方法的执行
3.2、根据ProxyFactory对象获取将要执行的目标方法的拦截器链;
List
3.2.1、List
1个默认的ExposeInvocationInterceptor和4个增强器
3.2.2、遍历所有的增强器,将其转为Interceptor;
registry.getInterceptors(advisor);
3.2.3、将增强器转为List
如果是MethodInterceptor,直接加入到集合中;如果不是使用AdvisorAdapter将增强器转为MethodInterceptor;转换完成返回MethodInterceptor数组;
3.3、如果没有拦截器链,直接执行目标方法
拦截器链(每一个通知方法又被包装成方法拦截器,利用MethodInterceptor机制)
3.4、如果有拦截器链,把需要执行的目标对象、目标方法、拦截器等信息传入创建一个CglibMethodInvocation对象,并调用Object retVal = mi.proceed();
3.5、拦截器的触发过程
3.5.1、如果没有拦截器执行目标方法,或者拦截器的索引和拦截器
3.5.2、链式获取每一个拦截器,拦截器执行invoke方法,每一个拦截器等待下一个拦截器执行完成返回以后来执行;
拦截器的机制,保证通知方法与目标方法的执行顺序
拦截器链

总结
1、@EnableAspectJAutoProxy开启aop功能
2、@EnableAspectJAutoProxy会给容器中注册一个组件AnnotationAwareAspectJAutoProxyCreator
3、AnnotationAwareAspectJAutoProxyCreator是一个后置处理器
4、容器的创建流程
4.1、registerBeanPostProcessors()注册后置处理器,创建AnnotationAwareAspectJAutoProxyCreator对象
4.2、finishBeanFactoryInitailization()初始化剩下的单实例bean
4.2.1、创建业务逻辑组件和切面组件
4.2.2、AnnotationAwareAspectJAutoProxyCreator拦截组件的创建过程
4.2.3、组件创建完之后,判断组件是否需要增强
是:切面的通知方法,包装成增强器(Advisor);给业务组件创建一个代理对象(cglib)
5、执行目标方法
5.1、代理对象执行目标方法
5.2、CglibAopProxy.intercept();
5.2.1、得到目标方法的拦截器链,(增强器包装成拦截器MethodInterceptor)
5.2.2、利用拦截器链式机制,依次进入每一个拦截器进行执行
5.2.3、效果:
正常执行:前置通知->目标方法->后置通知->返回通知
出现异常:前置通知->目标方法->后置通知->异常通知
5.5.2.7 - SpringBoot-01基础使用及原理
简介
Spring与SpringBoot
Spring的能力
参考 Spring 官网(https://spring.io/):What Spring can do
- Microservices
- Reactive
- Cloud
- Web apps
- Serverless
- Event Driven
- Batch
Spring的生态
参考 spring 官网:https://spring.io/projects/spring-boot
- web开发
- 数据访问
- 安全控制
- 分布式
- 消息服务
- 移动开发
- 批处理
- ....
Spring5 重大升级
响应式编程

支持Java8新特性
基于 Java8 的一些新特性,如:接口默认实现。重新设计源码架构等。
SpringBoot优缺点
优点(Features):
Create stand-alone Spring applications 创建独立 Spring 应用
Embed Tomcat, Jetty or Undertow directly (no need to deploy WAR files) 内嵌 web 服务器
Provide opinionated 'starter' dependencies to simplify your build configuration 自动 starter 依赖,简化构建配置
Automatically configure Spring and 3rd party libraries whenever possible 自动配置 Spring 以及第三方功能
Provide production-ready features such as metrics, health checks, and externalized configuration 提供生产级别的监控、健康检查及外部化配置
Absolutely no code generation and no requirement for XML configuration 无代码生成、无需编写XML
SpringBoot 是整合 Spring 技术的一站式框架。SpringBoot 是简化 Spring 技术栈的快速开发脚手架。
缺点:
- 迭代快,人称版本帝,需要时刻关注变化
- 封装太深,内部原理复杂,理解成本高
时代背景
微服务
James Lewis and Martin Fowler (2014) 提出微服务完整概念 https://martinfowler.com/microservices/。
- 微服务是一种架构风格
- 一个应用拆分为一组小型服务
- 每个服务运行在自己的进程内,即可独立部署和升级
- 服务之间使用轻量级HTTP交互
- 服务围绕业务功能拆分
- 可以由全自动部署机制独立部署
- 去中心化,服务自治。服务可以使用不同的语言、不同的存储技术
分布式
分布式的困难:
- 远程调用
- 服务发现
- 负载均衡
- 服务容错
- 配置管理
- 服务监控
- 链路追踪
- 日志管理
- 任务调度
- ... 分布式解决方案:SpringBoot + SpringCloud
云原生
原生应用如何上云:Cloud Native
上云的困难:
- 服务自愈
- 弹性伸缩
- 服务隔离
- 自动化部署
- 灰度发布
- 流量治理
- ......
官方文档
SpringBoot 官网:https://spring.io/projects/spring-boot
Document 路径: learn(SpringBoot 主页面) -> Documentation (各类文档,重点) -> Documentation Overview
SpringBoot 发布日志:overview (SpringBoot 主页面)-> release-notes(Github Wiki)
查看版本新特性:https://github.com/spring-projects/spring-boot/wiki#release-notes
SpringBoot2入门
系统要求
- Java8 & 兼容 Java14
- Maven 3.3+
- IDEA 2019.01.02+
maven设置
- 配置阿里云镜像加速
- profiles
Expand/Collapse Code Block
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23<mirrors> <mirror> <id>nexus-aliyun</id> <mirrorOf>central</mirrorOf> <name>Nexus aliyun</name> <url>http://maven.aliyun.com/nexus/content/groups/public</url> </mirror> </mirrors> <profiles> <profile> <id>jdk-1.8</id> <activation> <activeByDefault>true</activeByDefault> <jdk>1.8</jdk> </activation> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion> </properties> </profile> </profiles>
HelloWorld
参考官方文档:getting-started
创建maven工程
引入依赖
| |
创建主程序
| |
编写业务代码
| |
RestController = ResponseBody + Controller
测试
直接运行 main 方法
简化配置
application.properties 配置可参考:
Document 路径: learn(SpringBoot 主页面) -> Documentation (各类文档,重点) ->
注意:配置文件不生效需要注意项目 packaging 类型(默认是 jar 类型)。此外生效时 IDEA 会有 LSP 提示。
简化部署
| |
把项目打成 jar 包,直接在目标服务器执行即可
| |
注意:Windows 终端需要取消 cmd 快速编辑模式,否则点击终端会卡住。
自动配置原理
依赖管理
依赖管理
1 2 3 4 5 6 7 8 9 10 11 12 13 14该项目的 parent <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.0.RELEASE</version> </parent> 它的 parent <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.3.0.RELEASE</version> </parent> spring-boot-dependencies pom 中声明了各种 properties,几乎包含开发中所有的jarstarter starter 文档路径: learn(SpringBoot 主页面) -> Documentation (各类文档,重点) -> using Spring Boot
- 官方 starter 命令规则:spring-boot-starter-*
- 只要引入 starter,该场景常规需要的 jar 就自动引入
- SpringBoot 所有支持的场景:starters
- 第三方提供的简化开发的场景触发器:*-spring-boot-starter
- 修改版本号 在本 pom 中重写与 spring-boot-dependencies pom 相同的 properties,如:
| |
- 分析依赖树 IDEA 中 pom 文件中右键菜单:Diagrams
自动配置
自动配置 Tomcat
- 引入 Tomcat 依赖
- 配置 Tomcat
自动配置 SpringMVC
- 引入 SpringMVC 依赖
- 配置 SpringMVC 常用组件,如 dispatcherServlet
自动配置 Web 常见功能,如:字符编码问题
- 配置如:characterEncodingFilter、*ViewResolver、multipartResolver
默认的包结构
- 默认包扫描规则:主程序所在的包及其下面所有子包里面的组件都会被扫描出来。官网文档:Using Spring Boot # locating-the-main-class
- 指定扫描路径:
1 2 3 4 5 6 7 8 9 10@SpringBootApplication(scanBasePackages = "com.chnherb") 或 @ComponentScan(xxx) 其中 ComponentScan 与 SpringBootApplication 不能重复使用: @SpringBootApplication 等同于 @SpringBootConfiguration @EnableAutoConfiguration @ComponentScan(xxx)
配置各类默认值
- 默认配置值最终都是映射到某个类上
- 配置文件的值会绑定到每个类上,类会在容器中创建对象
按需加载所有自动配置项
- 引入哪些场景的 starter 才会加载哪个场景的自动配置
- 所有的自动配置功能都在 spring-boot-autoconfigure 包中
1 2 3 4 5 6 7路径:spring-boot-starter-* -> spring-boot-starter -> spring-boot-autoconfigure <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> <version>2.3.0.RELEASE</version> <scope>compile</scope> </dependency>
组件与配置
组件添加
Configuration注解
配置类里面使用 @Bean 标注在方法上给容器注册组件,默认是单实例的
配置类本身也是组件
proxyBeanMethods: 代理 bean 的方法
Full(proxyBeanMethods = true, 默认) 配置类组件之间有依赖关系,方法会被调用得到之前单实例组件
Lite(proxyBeanMethods = false) 配置类组件之间无依赖关系,加速容器启动过程,减少判断
其他主要注解
| |
ComponentScan和Import注解
ComponentScan 包扫描使用主要注解如 @Component 等的文件自动注入。
Import 注解需要标注在 组件类(如 @Configuration 或 @Controller 等等):
| |
Import 导入的对象 id 默认是全类名。
条件装配
满足 Conditional 条件时则进行组件注入。可通过 ctrl + H 快捷键查看 Conditional 类的层次结构。

xml配置文件引入
引入资源:
| |
bean.xml 文件
IDEA 中新建 xml 文件选择“XML Configuration File” -> “Spring Config” 即可创建带有 schema 的 xml 文件
| |
配置绑定
读取 properties 文件内容,并把它封装到 JavaBean 中。
原来的做法(代码读取文件逐步解析):
| |
SpringBoot 内置相关注解可自动解析配置文件。
前提:只有容器中的组件,才能拥有 SpringBoot 提供的这些功能,所以需要配合其他注解使用,如 Component 注解等。
使用方式有如下几种:
- Component等注解 + ConfigurationProperties 注解
- EnableConfigurationProperties + ConfigurationProperties 注解
ConfigurationProperties
Component等注解 + ConfigurationProperties 注解
配置 application.propertites:
| |
Bean 中引入:
| |
EnableConfigurationProperties
EnableConfigurationProperties + ConfigurationProperties 注解
场景:第三方包中的 Bean 没有 Component 等相关注解时,需要绑定 properties。
在组件中使用 EnableConfigurationProperties 注解:
- 开启配置绑定功能
- 将组件自动注入到容器中 使用方式:
| |
Bean 文件:
| |
注解加载配置值
@Value("${"xxx"}")
@ConfigurationProperties(prefix = "test")
yaml配置文件
| |
| |
自动配置原理
引导加载自动配置类
首先看 SpringBootApplication 注解实现代码:
| |
核心是上面三个注解组成。
SpringBootConfiguration
看该注解实现代码:
| |
可以看出该注解核心就是一个 Configuration 注解,代表当前是一个配置类。
ComponentScan
指定扫描哪些包。这里不深入解释。
EnableAutoConfiguration
看该注解实现代码:
| |
AutoConfigurationPackage
自动配置包。(自动导入用户定义的组件)
| |
该注解的核心是导入 AutoConfigurationPackages.Registrar 类,而该类是利用 Register 给容器中导入一系列组件。 这里可以通过设置断点调试源码:
| |
metadata表示注解的原信息,这里就是指 SpringBoot 启动的 Main 类,具体是 MainApplication。 所以通过源码调试可以得出:Registrar 就是将制定的一个包下的所有组件导入(Main 所在的包)。
AutoConfigurationImportSelector
自动配置加载器。(自动导入各种自动配置器)
| |
该类利用 getAutoConfigurationEntry 方法给容器批量导入一些组件。
| |
调用 getCandidateConfigurations 方法获取到所有需要导入到容器中的配置类。
| |
利用Spring工厂加载器加载得到所有的组件。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static List<String> loadFactoryNames(Class<?> factoryType, @Nullable ClassLoader classLoader) {
String factoryTypeName = factoryType.getName();
return loadSpringFactories(classLoader).getOrDefault(factoryTypeName, Collections.emptyList());
}
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
MultiValueMap<String, String> result = cache.get(classLoader);
if (result != null) {
return result;
}
try {
Enumeration<URL> urls = (classLoader != null ?
// FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories"
classLoader.getResources(FACTORIES_RESOURCE_LOCATION) :
ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
result = new LinkedMultiValueMap<>();
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
UrlResource resource = new UrlResource(url);
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
for (Map.Entry<?, ?> entry : properties.entrySet()) {
String factoryTypeName = ((String) entry.getKey()).trim();
for (String factoryImplementationName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
result.add(factoryTypeName, factoryImplementationName.trim());
}
}
}
cache.put(classLoader, result);
return result;
}
catch (IOException ex) {
throw new IllegalArgumentException("Unable to load factories from location [" +
FACTORIES_RESOURCE_LOCATION + "]", ex);
}
}
从 META-INF/spring.factories 位置加载一个文件,默认扫描所有依赖包中该位置的文件。核心包:spring-boot-autoconfigure-2.3.0.RELEASE.jar,该包中包括所有自动配置的类(Auto Configure 类127个)。即该文件写死了 SpringBoot 启动需要给容器中加载的所有配置类。
按需开启自动配置项
虽然上面讲的 127 个场景的所有自动配置(xxxAutoConfiguration)启动时默认全部加载,但是按照条件装配规则(Conditional 注解),最终会按需配置。
比如 AopAutoConfiguration 类,仅当导入 aspectj 下面的 Advice 类才会配置。
修改默认配置
举例(文件上传解析器):
| |
SpringBoot 默认会在底层代码中配置好所有的组件,如果用户配置过了以用户的优先,不会加载默认配置的(ConditionalOnMissingBean)。
总结:
- SpringBoot 先加载所有的自动配置类(xxxAutoConfiguration)
- 每个自动配置类按照条件进行生效,默认绑定配置文件的值(xxxProperties绑定配置文件)
- 生效的配置会给容器中装配很多组件
- 容器中存在这些组件即代表相应功能存在
- 定制化配置
- 优先使用用户创建的 Bean
- 修改组件配置文件的值 流程:
xxxAutoConfiguration -> 组件 -> xxxProperties -> application.properties
最佳实践
- 引入场景依赖,参考 Using Spring Boot # starters
- 查看自动配置项
- 一般引入的场景都自动配置了
- application.properties 中配置 debug=true开启自动配置报告。Negative(不生效)、Positive(生效)
- 自定义修改
- 参考文档修改配置项
- Application Properties
- 参考源码:xxxProperties
- 自定义配置 Bean,使用 Bean/Component 等注解
- 自定义器:xxxCustomizer
- ...
- 参考文档修改配置项
配置文件
配置文件及提示
properties配置文件
同之前的 properties 配置文件用法
yaml配置文件
语法不太一样
配置提示
文件路径: learn(SpringBoot 主页面) -> Documentation (各类文档,重点) ->
Configuration Metadata # Configuring the Annotation Processor
增加配置文件的提示功能:
1、导入依赖
| |
2、重新启动程序或 maven 重新导包 3、打包时过滤该包
| |
配置文件路径及优先级
四个配置路径
- 当前项目根目录下的 config 目录下
- 当前项目的根目录下
- resources 目录下的 config 目录下
- resources 目录下 优先级从高到底

自定义配置路径
另外我们也可以在启动项目的时候,指定配置文件的位置,这个的操作主要针对于已经打包好的项目,无法修改配置文件了
| |
自定义配置文件名称
| |
多条命令
| |
多模块使用对应配置文件
配置文件:
| |
激活环境:
| |
其他 module 的 yml 需要读取公共 module 的 yml,那么需要在其他 module 的 yml 配置:
| |
参考: https://blog.csdn.net/cw_hello1/article/details/79639448 https://blog.csdn.net/xue_mind/article/details/106494732
yml依赖
使用外部yml文件的两种方法。
参考:https://blog.csdn.net/Lonely_Devil/article/details/81875890
application.yml与bootstrap.yml的区别
开发小技巧
Lombok
简化开发:
- 简化 JavaBean 开发
- 简化 log 开发(Slf4j 注解) 引入 pom 依赖:
| |
IDEA 中搜索安装 lombok 插件即可。
dev-tools
热更新开发工具。
引入 pom 依赖:
| |
项目或者页面修改以后:Ctrl+F9;
Reference
https://github.com/javastacks/spring-boot-best-practice
加载配置文件源码:
https://blog.csdn.net/chengkui1990/article/details/79866499
5.5.2.8 - SpringBoot-02Web开发及请求响应处理
简介
Spring Boot is well suited for web application development. You can create a self-contained HTTP server by using embedded Tomcat, Jetty, Undertow, or Netty. Most web applications use the spring-boot-starter-web module to get up and running quickly. You can also choose to build reactive web applications by using the spring-boot-starter-webflux module.
文档路径:文档路径: learn(SpringBoot 主页面) -> Documentation (各类文档,重点) -> Web
SpringMVC自动配置简介
SpringBoot 为 SpringMVC 提供了大多数场景的自动配置,这些自动配置添加了如下 Spring 的默认特性:
- 包括
ContentNegotiatingViewResolver(内容协商视图解析器)和BeanNameViewResolver(BeanName视图解析器) 等 bean - 支持静态资源包括 WebJars (covered later in this document)
- 自动注册
Converter、GenericConverter和Formatter等 bean - 支持
HttpMessageConverters(covered later in this document) - 自动注册
MessageCodesResolver(国际化使用)(covered later in this document) - 静态
index.html支持 - 自定义 Favicon
- 自动使用
ConfigurableWebBindingInitializerbean (DataBind 负责将请求数据绑定到 JavaBean ) (covered later in this document) 自定义 MVC customizations (interceptors, formatters, view controllers, and other features),可以通过添加WebMvcConfigurer类型的@Configuration而不是@EnableWebMvc
如果提供自定义的
RequestMappingHandlerMapping, RequestMappingHandlerAdapter, 或 ExceptionHandlerExceptionResolver, 并定制化Spring Boot MVC,可以声明 WebMvcRegistrations 类型的 bean 并使用其提供这些自定义组件的实例。
完全接管 SpringMVC,可以添加@Configuration 和 @EnableWebMvc注解或另外添加 @Configuration 和DelegatingWebMvcConfiguration 。
基础功能介绍
静态资源访问
文档:spring-mvc # static-content
静态资源目录
只要静态资源放在类路径的 resources 下:/static 或/public 或 /resources 或 /META-INF/resources中即可。
访问方式:当前项目 + 根路径/ + 静态资源名称(无需增加目录名)
原理:静态资源映射 /**,请求到达先查找 controller ,不能匹配然后交给静态资源处理器,没找到就 404。
静态资源前缀
一般配置静态资源带有前缀,为了方便做权限控制。
| |
访问方式:当前项目 + static-path-pattern + 静态资源名称
静态资源路径
| |
webjars
自动映射 /webjars/**
引入依赖:
| |
访问方式:当前项目 + /webjars/jquery/3.5.1/jquery.js (即依赖包的内容路径,可直接查看)
欢迎页
- 静态资源路径下
- 可以配置静态资源路径
- 不能配置静态资源前缀
自定义Favicon
favicon.ico 放在静态资源目录下即可。
配置静态资源前缀会影响该功能。
静态资源配置原理
上文讲过 SpringBoot 启动时会默认加载 xxxAutoConfiguration类(自动配置类),如 spring-boot-autoconfigure jar 包中的自动配置类,这里 web 启动会加载其中的 web 包中的相关配置类,如加载 WebMvcAutoConfiguration
具体配置项:
| |
注意 EnableConfigurationProperties 注解,配置文件的相关属性 WebMvcProperties(spring.mvc)、ResourceProperties(spring.resources)。 配置类只有一个有参构造器
注意这里 jar 版本 2.3.0-RELEASE 没有最后两个参数,升级到 2.3.7-RELEASE 即可。
Expand/Collapse Code Block
| |
资源处理默认规则
Expand/Collapse Code Block
| |
欢迎页处理规则
HandlerMapping:处理器映射,保存了每一个 Handler 能处理的请求。
Expand/Collapse Code Block
| |
请求参数处理
请求映射
@xxxMapping 如 RequestMapping 注解
Rest 风格支持(使用 HTTP 请求方式来表示对资源的操作)。
核心 Filter:HiddenHttpMethodFilter。
用法:表单 method=post,隐藏域 _method=put
Rest使用
Rest 接口:
Expand/Collapse Code Block
| |
HTML 访问页面:Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
测试 REST:
<form action="/user" method="get">
<input value="REST-GET SUBMIT" type="submit"/>
</form>
<form action="/user" method="post">
<input value="REST-POST SUBMIT" type="submit"/>
</form>
无效:
<form action="/user" method="put">
<input value="REST-PUT SUBMIT" type="submit"/>
</form>
<form action="/user" method="delete">
<input value="REST-DELETE SUBMIT" type="submit"/>
</form>
有效:
<form action="/user" method="post">
<input name="_method", type="hidden" value="PUT">
<input value="REST-PUT SUBMIT" type="submit"/>
</form>
<form action="/user" method="post">
<input name="_method", type="hidden" value="DELETE">
<input value="REST-DELETE SUBMIT" type="submit"/>
</form>
手动开启:
| |
Rest原理
背景知识:
HTML4 / XHTML1 仅允许以表格形式进行 GET 和 POST 请求。<form method="put">是无效的HTML,将被视为<form> ,即发送一个GET请求。
为了解决这个问题,SpringBoot 做了一些适配功能,步骤如下:
- 表单提交会带上 _method=PUT
- 接受请求被 HiddenHttpMethodFilter 拦截
- 请求是否正常且是 POST 方式
- 获取到 _method 的值
- 允许的请求方式:PUT/DELETE/PATCH
- 原生 request(post),包装模式 requestWarpper 重写 getMethod 方式
- 过滤器链放行使用的是 warpper 对象 注意:如果是使用 Rest 客户端工具,直接发送 Put、delete 等请求,无需配置 filter。
- 请求是否正常且是 POST 方式
| |
该类继承了 HiddenHttpMethodFilter 类
| |
核心逻辑:HiddenHttpMethodFilter 的 doFilterInternal 方法
| |
request 请求被包装成 HttpMethodRequestWrapper(继承了 HttpServletRequest),替换了新的 method。
| |
自定义 rest methodParam
| |
请求映射原理
类的继承关系(快捷键:ctrl + H)
DispatcherServlet:
| |

方法的调用关系:
| |
核心方法:doDispatch(可断点查看)Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// org.springframework.web.servlet.DispatcherServlet#doDispatch
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
try {
ModelAndView mv = null;
Exception dispatchException = null;
try {
// 检查文件上传请求
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
// Determine handler for the current request.
// 找到当前请求处理的 handler
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
// Determine handler adapter for the current request.
// handler 适配器
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// Process last-modified header, if supported by the handler.
String method = request.getMethod();
boolean isGet = "GET".equals(method);
if (isGet || "HEAD".equals(method)) {
long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
return;
}
}
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
}
...
}
其中 HandleMapping 包含 5 个

RequestMappingHandlerMapping:保存了所有 @RequestMapping 和 handler 的映射规则。其 mappingRegistry.registry 保存了 RequestMapping 所有的路径匹配信息。
小结:
所有请求映射都在HandlerMapping 中:
- SpingBoot 自动配置了欢迎页的 HandlerMapping,访问 / 能访问到 index.html
- 处理请求时会遍历所有的 HanlderMapping 匹配请求信息
- 匹配上则返回该 HanlderMapping 的 handler
- 没有匹配上则继续遍历下一个 HandlerMapping
- 自定义映射处理时,往容器中注入 HandlerMapping 即可,即自定义 HanlderMapping
常用参数注解
注解
@PathVariable、@RequestHeader、@ModelAttribute、@RequestParam、@MatrixVariable、@CookieValue、@RequestBody
测试 demo:
Expand/Collapse Code Block
| |
Expand/Collapse Code Block
| |
测试 html:
Expand/Collapse Code Block
| |
SpringBoot 默认关闭矩阵变量,开启方式:
Expand/Collapse Code Block
| |
开启原理: 查看 WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter 类中的 configurePathMatch 方法,
UrlPathHelper 中 removeSemicolonContent 变量默认 true,默认移除分号后的内容,即默认关闭矩阵变量功能。
Servlet API
除了上面常见的注解,还支持内置的 Servlet API 对象:
WebRequest、ServletRequest、MultipartRequest、 HttpSession、javax.servlet.http.PushBuilder、Principal、InputStream、Reader、HttpMethod、Locale、TimeZone、ZoneId
如 ServletRequestMethodArgumentResolver 能够解析以上部分参数(见下文参数处理原理 debug 得到)
复杂参数
Map
Model(map、model里面的数据会被放在request的请求域 request.setAttribute)Errors/BindingResult
RedirectAttributes( 重定向携带数据)
ServletResponse(response)
SessionStatus
UriComponentsBuilder
ServletUriComponentsBuilder
Map 和 Model 类型的参数,会返回 mavContainer.getModel(),其类型为 BindingAwareModelMap,既是 Model 也是 Map。
参数处理原理
- HandlerMapping 中找到能处理请求的 Hanlder(Controller.method)
- 为当前 Handler 找到 HandlerAdapter
HandlerAdapter
| |

介绍下几个 HandlerAdapter:
- 0 - 支持方法上标注 @RequestMapping
- 1 - 支持函数式编程的
- ...
1 2 3 4 5 6 7 8 9 10// org.springframework.web.servlet.DispatcherServlet#doDispatch mv = ha.handle(processedRequest, response, mappedHandler.getHandler()); // org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter#handle @Override @Nullable public final ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return handleInternal(request, response, (HandlerMethod) handler); }
RequestMappingHandlerAdapter 处理:
| |
执行目标方法
| |
参数解析器
this.argumentResolvers

该参数解析器实现的接口(cmd+F12 查看所有方法):

- 当前解析器是否支持解析参数
- 支持就调用 resolveArgument
调用并处理
| |
执行目标方法
| |
获取方法参数值
Expand/Collapse Code Block
| |
遍历判断参数解析器是否支持
| |
解析参数值
| |
自定义参数类型
ServletModelAttributeMethodProcessor 参数处理器支持
| |
WebDataBinder:web数据绑定器,将请求参数的值绑定到指定的 JavaBean 里面。 GenericConversionService:设置每个属性值时,使用 converter 转换数据类型。
自定义参数转换器
在 WebDataBinder 中添加自定义的 Converter 可以自定义类型转换器:
Expand/Collapse Code Block
| |
目标方法执行完成
将所有的数据都放在 ModelAndViewContainer,包含要去的页面地址 View 和 Model 数据。
Expand/Collapse Code Block
| |
处理派发结果
| |
数据响应与内容协商
数据响应
- 响应页面
- 响应数据
- JSON
- XML
- xls
- 图片、音视频...
- 自定义协议数据 内容协商:浏览器以请求头的方式告诉服务器能接受什么类型的内容。
响应JSON
web starter 自动引入了 json 相关包。
实现条件:
1、json 相关包
2、@ResponseBody
响应处理原理
基本大的流程同上文请求参数处理
| |
返回值处理器

处理返回值方法
Expand/Collapse Code Block
| |
返回值处理器先判断是否支持该类型返回值,支持则调用 handleReturnValue 方法处理,同处理请求参数。
MethodProcessor
因为业务代码标注了 ResponseBody 注解,所以是使用 RequestResponseBodyMethodProcessor
| |
MessageConvertersExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor#writeWithMessageConverters
protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
...
MediaType selectedMediaType = null;
MediaType contentType = outputMessage.getHeaders().getContentType();
boolean isContentTypePreset = contentType != null && contentType.isConcrete();
if (isContentTypePreset) {
if (logger.isDebugEnabled()) {
logger.debug("Found 'Content-Type:" + contentType + "' in response");
}
selectedMediaType = contentType;
}
else {
HttpServletRequest request = inputMessage.getServletRequest();
// 获取浏览器接受的数据类型,浏览器请求头 Accept 字段
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
// 获取服务器能够处理的数据类型
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
if (body != null && producibleTypes.isEmpty()) {
throw new HttpMessageNotWritableException(
"No converter found for return value of type: " + valueType);
}
// 可以使用的媒体类型
List<MediaType> mediaTypesToUse = new ArrayList<>();
// 浏览器和服务器的数据类型进行匹配
for (MediaType requestedType : acceptableTypes) {
for (MediaType producibleType : producibleTypes) {
if (requestedType.isCompatibleWith(producibleType)) {
mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
}
}
}
if (mediaTypesToUse.isEmpty()) {
if (body != null) {
throw new HttpMediaTypeNotAcceptableException(producibleTypes);
}
if (logger.isDebugEnabled()) {
logger.debug("No match for " + acceptableTypes + ", supported: " + producibleTypes);
}
return;
}
MediaType.sortBySpecificityAndQuality(mediaTypesToUse);
for (MediaType mediaType : mediaTypesToUse) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) {
selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
break;
}
}
if (logger.isDebugEnabled()) {
logger.debug("Using '" + selectedMediaType + "', given " +
acceptableTypes + " and supported " + producibleTypes);
}
}
HttpMessageConverter
HttpMessageConverter 判断是否支持将当前类型转换为 MediaType 类型的数据。如对象转 JSON 或 JSON 转对象,具体调用该接口的方法。
Expand/Collapse Code Block
| |
write
| |
writeInternal
| |
AbstractJackson2HttpMessageConverter (该类子类 MappingJackson2HttpMessageConverter) 该类利用底层的 jackson 的 objectMapper 进行转换。
| |
小结:
- 返回值处理器判断是否支持该类型
- 返回值处理器调用 handleReturnValue 进行处理
- RequestResponseBodyMethodProcessor 处理标记 ResponseBody 注解的方法
- MessageConverters 处理:将数据写为 JSON
- 内容协商(浏览器请求头带有自身接受数据类型)
- 服务器根据自身能力决定生产出什么类型的数据
- 利用 MappingJackson2HttpMessageConverter 将对象写为 json
- MessageConverters 处理:将数据写为 JSON
内容协商
XML响应
默认的请求是以 JSON 方式响应,如果想使用 XML 方式响应方法如下:
引入依赖:
| |
浏览器请求时响应的是 XML,原因:引入 XML 处理依赖包表示服务端可以处理 XML,浏览器 Request Headers 的 Accpet 字段为:
| |
表示 XML 优先于 JSON(没有 JSON)。 注意:如果 Accept 为 /,那么返回是 JSON。
内容协商原理
- 判断当前响应头中是否存在已经确定的媒体类型 MediaType
- 获取客户端支持接受的内容类型(请求头 Accept 字段)
- 遍历所有的 MessageConverter,判断是否支持操作当前类型的对象
- 找到支持当前类型对象的 converter,统计其支持的媒体类型
- 获取客户端需要的媒体类型和服务端支持的媒体类型
- 进行内容协商,匹配最佳媒体类型
- 调用支持将对象转为最佳匹配媒体类型的 converter 进行转换
导入默认MessageConverter
WebMvcAutoConfiguration 自动配置类启动时会配置 MessageConverters
| |
HttpMessageConverters 初始化会给 converters 赋值
| |
获取默认的 Converters
| |
默认的 Converter 从父类获取
| |
更多的 converter 就在 addDefaultHttpMessageConverters 方法中Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#addDefaultHttpMessageConverters
protected final void addDefaultHttpMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
messageConverters.add(new ByteArrayHttpMessageConverter());
messageConverters.add(new StringHttpMessageConverter());
messageConverters.add(new ResourceHttpMessageConverter());
messageConverters.add(new ResourceRegionHttpMessageConverter());
...
if (jackson2XmlPresent) {
Jackson2ObjectMapperBuilder builder = Jackson2ObjectMapperBuilder.xml();
if (this.applicationContext != null) {
builder.applicationContext(this.applicationContext);
}
messageConverters.add(new MappingJackson2XmlHttpMessageConverter(builder.build()));
}
else if (jaxb2Present) {
messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
}
...
}
初始化用于判断的 present 变量:
| |
可以发现自动导入各种 converter 是通过类工具判断项目中是否导入对应的 class。
开启参数方式内容协商
默认的内容协商管理器 contentNegotationManager 只有一个策略根据请求的 Accept 来决定,可以增加根据请求参数来进行内容协商。方法如下:
| |
发起请求时带上参数 format=xml 或 format=json 即可(仅支持这两种)。
自定义MessageConverter
实现多协议数据兼容。
标记 ResponseBody 注解,调用 RequestResponseBodyMethodProcessor 处理
Processor 处理方法返回值,通过 MessageConverter 处理
所有 MessageConverter 组织起来可以支持各种媒体类型的数据操作(读写)
内容协商找到最佳的 MessageConverter 自定义步骤:
添加自定义的 MessageConverter 到系统容器中
系统会自动统计出所有 MessageConverter 支持操作的数据类型
客户端内容协商
HerbMessageConverter
Expand/Collapse Code Block
| |
WebMvcConfigurer配置类
| |
调用方式
| |
注意这里不支持基于参数的请求方式:
| |
自定义内容协商协议
为了解决上面说的自定义 MessageConverter 不支持基于参数的请求方式,在 WebMvcConfigurer 配置中覆盖 configureContentNegotiation 方法即可
| |
这里请求参数加上 format 可以发现有自定义响应。 注意事项:自定义功能可能会导致默认功能失效,如这里的 HeaderContentNegotiationStrategy,如果自定义就无法支持请求头的转换方式。
5.5.2.9 - SpringBoot-03Web组件
视图解析与模板引擎
视图解析
视图处理处理方式
- 转发
- 重定向
- 自定义视图
视图解析原理流程
核心方法:
| |
- 目标方法处理过程中,所有数据都会放在 ModelAndViewContainer 中,包括数据和视图地址。
- 方法的参数是自定义类型对象(从请求参数中确定),会重新放在 ModelAndViewContainer 中。
- 任何目标方法执行完成后都会返回 ModelAndView(数据和视图地址)
- processDispatchResult 处理派发结果(页面如何响应)
- render(mv, request, response); 进行页面渲染逻辑,根据方法的String返回值得到View对象
- 遍历所有的视图解析器能否支持根据当前返回值得到View对象
- 创建RedirectView 对象(org.thymeleaf.spring5.view.ThymeleafViewResolver#createView)
- view.render(mv.getModelInternal(), request, response) 视图解析:
- render(mv, request, response); 进行页面渲染逻辑,根据方法的String返回值得到View对象
- 返回值以 forward: 开始: new InternalResourceView(forwardUrl); 转发request.getRequestDispatcher(path).forward(request, response);
- 返回值以 redirect: 开始: new RedirectView() , render就是重定向
- 返回值是普通字符串: new ThymeleafView()
模板引擎-Thymeleaf
thymeleaf简介
Thymeleaf is a modern server-side Java template engine for both web and standalone environments.
服务端Java模板引擎
thymeleaf 官网地址:https://www.thymeleaf.org/
doc:documentation ->(Read online) usingthymeleaf
基本语法
表达式
https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#standard-expression-syntax
| 表达式名字 | 语法 | 用途 |
|---|---|---|
| 变量取值 | ${...} | 获取请求域、session域、对象等值 |
| 选择变量 | *{...} | 获取上下文对象值 |
| 消息 | #{...} | 获取国际化等值 |
| 链接 | @{...} | 生成链接 |
| 片段表达式 | ~{...} | jsp:include作用,引入公共页面片段 |
字面量
文本值: **'one text' , 'Another one!' ,…**数字: **0 , 34 , 3.0 , 12.3 ,…**布尔值: true , false
空值: null
变量: one,two,.... 变量不能有空格
文本操作
字符串拼接: +
变量替换: |The name is ${name}|
数学运算
运算符: + , - , * , / , %
布尔运算
运算符: and , or
一元运算: ! , not
比较运算
比较: **> , < , >= , <= ( gt , lt , ge , le )**等式: == , != ( eq , ne )
条件运算
If-then: (if) ? (then)
If-then-else: (if) ? (then) : (else)
Default: (value) ?: (defaultvalue)
特殊操作
无操作: _
设置属性值
-th:attr
设置单个值
| |
设置多个值
| |
以上两个的代替写法 th:xxxx
| |
所有h5兼容的标签写法
https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#setting-value-to-specific-attributes
迭代
| |
| |
条件运算
| |
| |
属性优先级
https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#attribute-precedence
| Order | Feature | Attributes |
|---|---|---|
| 1 | Fragment inclusion | th:insert th:replace |
| 2 | Fragment iteration | th:each |
| 3 | Conditional evaluation | th:if th:unless th:switch th:case |
| 4 | Local variable definition | th:object th:with |
| 5 | General attribute modification | th:attr th:attrprepend th:attrappend |
| 6 | Specific attribute modification | th:value th:href th:src ... |
| 7 | Text (tag body modification) | th:text th:utext |
| 8 | Fragment specification | th:fragment |
| 9 | Fragment removal | th:remove |
thymeleaf使用
引入starter
using spring boot # build-systems.starters
| |
自动配置原理
| |
自动配置的策略:
1、所有的配置值都在 ThymeleafProperties
| |
2、配置了 SpringTemplateEngine 3、配置了 ThymeleafViewResolver
用户只要导入依赖包自动配置然后直接开发页面即可。
页面开发
controller
| |
模板页面
| |
拦截器
HandlerInterceptor接口
Expand/Collapse Code Block
| |
配置拦截器
| |
拦截器原理
核心代码入口:
| |
- 根据当前请求找到 HandlerExecutionChain,即 handler 及其所有拦截器。mappedHandler 中包含了 HandlerInterceptor 列表
- 顺序执行 所有拦截器的 preHandle 方法
- 如果返回为 true 则执行下一个拦截器的 preHandle
- 如果返回为 false 则倒序执行已经执行拦截器的 afterCompletion 方法
- 任何一个拦截器返回 false,直接跳出不会执行目标方法
- 所有拦截器都返回 true,执行目标方法
- 倒序执行所有拦截器的 postHandle 方法
- 前面步骤任何异常都会直接倒序触发已经执行了拦截器的 afterCompletion 方法
- 页面成功渲染之后,也会倒序触发 afterCompletion 方法 流程图如下:

文件上传
页面表单
| |
文件上传
Expand/Collapse Code Block
| |
自动配置原理
文件上传自动配置类 MultipartAutoConfiguration 自动配置了 StandardServletMultipartResolver(文件上传解析器)和 MultipartProperties
自动配置代码:
| |
文件上传源码入口:
| |
原理步骤:
- 请求进来使用文件上传解析器判断(isMultipart)并封装(resolveMultipart,返回MultipartHttpServletRequest)文件上传请求
- 参数解析器来解析请求中的文件内容封装成 MultipartFile
- 将request中文件信息封装为一个Map;MultiValueMap<String, MultipartFile> FileCopyUtils。实现文件流的拷贝。
异常处理
官方文档:web # error-handling
错误处理
默认规则
默认情况下,Spring Boot提供
/error处理所有错误的映射对于机器客户端,它将生成JSON响应,其中包含错误,HTTP状态和异常消息的详细信息。对于浏览器客户端,响应一个“ whitelabel”错误视图,以HTML格式呈现相同的数据
1 2 3 4 5 6 7 8// json格式:(页面可直接取该变量值进行显示) { "timestamp": "xxx", "status": 404, "error": "xxxx", "message": "", "path": "" }要完全替换默认行为,可以实现
ErrorController并注册该类型的Bean定义,或添加ErrorAttributes类型的组件以使用现有机制但替换其内容。error/下的4xx,5xx页面会被自动解析;
定制错误处理逻辑
- 自定义错误页
- error/404.html error/5xx.html;有精确的错误状态码页面就匹配精确,没有就找 4xx.html;如果都没有就触发白页
- @ControllerAdvice+@ExceptionHandler处理全局异常;底层是 ExceptionHandlerExceptionResolver 支持的
- @ResponseStatus+自定义异常 ;底层是 ResponseStatusExceptionResolver ,把responsestatus注解的信息底层调用 response.sendError(statusCode, resolvedReason);tomcat发送的/error
- Spring底层的异常,如 参数类型转换异常;DefaultHandlerExceptionResolver 处理框架底层的异常。
- response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
- 自定义实现 HandlerExceptionResolver 处理异常;可以作为默认的全局异常处理规则
- ErrorViewResolver 实现自定义处理异常;
- response.sendError 。error请求就会转给controller
- 异常没有任何人能处理。tomcat底层 response.sendError。error请求就会转给controller
- basicErrorController 要去的页面地址是 ErrorViewResolver ;
异常处理自动配置原理
自动配置类:
| |
该类用于自动配置异常处理规则。 容器中的组件:
1、DefaultErrorAttributes
定义错误页面中可以包含哪些数据,如 timestamp、error、message 等
| |
2、BasicErrorController
处理默认 /error 路径的请求。
根据请求的不同(produces = MediaType.TEXT_HTML_VALUE)响应页面或者 json。
| |
3、BeanNameViewResolver
视图解析器,按照返回的视图名作为组件的 id 去容器中寻找 View 对象。
| |
4、DefaultErrorViewResolver
如果发生错误,会以HTTP的状态码 作为视图页地址(viewName),寻找真正的页面如:error/404、5xx.html
| |
异常处理流程
1、执行目标方法,目标方法任何异常都会被 catch 且标记请求结束,并用 dispatchException
保存异常信息。
| |
2、进入视图解析流程
| |
3、处理 handler 异常
| |
1)遍历所有的处理器异常解析器 handlerExceptionResolvers 2)系统默认的异常解析器 DefaultErrorAttributes 先处理异常,会把异常信息保存到 request 域中且返回 null。
- 没有能处理的最终底层会发送 /error 请求,继而被 BasicErrorController 处理
- 解析错误试图,遍历所有的 ErrorViewResolver
- 默认的 DefaultErrorViewResolver 将响应状态码作为错误页的地址
- 模板引擎最终响应 error/xxx.html
Web原生组件注入
官方参考文档:web # web.servlet.embedded-container
Servlets, Filters, and listeners
根据官方文档描述,有如下两种方式可以注入上述组件。
Servlet API
1、编写自定义 MyServlet
| |
2、启动类配置 @ServletComponentScan(basePackages = "com.chnherb.boot") 3、编写自定义 MyFilter
| |
4、编写自定义 MyListener
| |
RegistrationBean
ServletRegistrationBean, FilterRegistrationBean, and ServletListenerRegistrationBean 。
推荐使用该方式。
Expand/Collapse Code Block
| |
扩展:DispatcherServlet注册
问:为什么上文 servlet 路径 /my 等没有被 spring 的 DispatchServlet 拦截?
DispatcherServlet 通过 DispatcherServletAutoConfiguration 注册:
- 容器中自动配置了 DispatcherServlet 属性绑定到 WebMvcProperties;对应的配置文件配置项是 spring.mvc。
- 通过 ServletRegistrationBean
把 DispatcherServlet 配置进来。 - 默认映射的是 / 路径。 Tomcat-Servlet 精准优先匹配原则:
多个Servlet都能处理到同一层路径,精确优选匹配原则,即能匹配到 /my 就不会匹配 /。

嵌入式Servlet容器
根据官方文档 web # web.servlet.embedded-container 介绍,支持的嵌入式 Servlet 容器有 Tomcat、Jetty 和 Undertow.
基本原理
(参考 web # web.servlet.embedded-container.application-context)
- SpringBoot 应用启动发现当前是 Web 应用则会导入 tomcat
- Web 应用会创建一个 web 版的 ioc 容器 ServletWebServerApplicationContext
- ServletWebServerApplicationContext 容器启动寻找 ServletWebServerFactory(Servlet 的服务器工厂) 并引导创建 Servlet 服务器。
- SpringBoot 底层默认有很多 WebServer 工厂如 TomcatServletWebServerFactory、JettyServletWebServerFactory、UndertowServletWebServerFactory
- 底层会有一个自动配置类 ServletWebServerFactoryAutoConfiguration,该类有 Import 了 ServletWebServerFactoryConfiguration
- ServletWebServerFactoryConfiguration 条件装配(根据导入的包)上面三个 XxxWebServerFactory
- 以 TomcatServletWebServerFactory 为例会创建出 TomcatWebServer 其实就是将手动启动服务器自动化实现。
创建 WebServer 代码流程
| |
切换服务器
| |
自定义Servlet容器
- 修改配置文件 server.xxx
- 直接自定义 ConfigurableServletWebServerFactory(ServletWebServerFactory)
- 实现 WebServerFactoryCustomizer
,把配置文件的值和 ServletWebServerFactory 进行绑定 (参考:web # web.servlet.embedded-container.customizing.programmatic) 1 2 3 4 5 6 7@Component public class MyWebServerFactoryCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> { @Override public void customize(ConfigurableServletWebServerFactory server) { server.setPort(9000); } }
定制化总结
参考 web # web.servlet.spring-mvc.auto-configuration
基本思路是:
- 导入场景 starter
- xxxAutoConfiguration
- 自动配置类导入相关组件
- 绑定 xxxProperties
- 绑定配置文件
定制化方法
- 修改配置文件
- 自定义配置类 xxxConfiguration + Bean 注入(替换或增加默认组件)
- 自定义配置类并实现 WebMvcConfigurer + Bean 注入
- 自定义 Customizer
- 使用注解@EnableWebMvc + 实现 WebMvcConfigurer + bean 全面接管 SpringMVC,所有规则重新配置(慎用)
EnableWebMvc注解原理
前提:WebMvcAutoConfiguration 默认的SpringMVC的自动配置功能类。包含静态资源、欢迎页等。
@EnableWebMvc 注解会 @Import(DelegatingWebMvcConfiguration.class)。
DelegatingWebMvcConfiguration 只保证 SpringMVC 最基本的使用:
- 将系统中所有的 WebMvcConfigurer 合并一起作用生效
- 该类继承 WebMvcConfigurationSupport
- WebMvcConfigurationSupport 会自动配置一些非常底层的组件,如 RequestMappingHandlerMapping 但是 WebMvcAutoConfiguration 类条件配置生效是
| |
所以当开启 @EnableWebMvc 注解时,WebMvcAutoConfiguration 就不满足条件配置了。
5.5.2.10 - SpringBoot-04数据访问
HikariDataSource
导入JDBC
| |
jdbc maven 包:

注意:mysql 驱动版本要和实际安装的数据库版本一致。比如本文中 mysql 驱动默认的版本在 spring-boot-starter-parent -> spring-boot-dependencies 中默认为:
| |
如果机器上实际安装的版本不一样就需要修改,方法如下: 1、直接依赖引入具体版本(maven的就近依赖原则)
| |
2、重新声明版本(maven的属性的就近优先原则)
| |
自动配置
自动配置包 spring-boot-autoconfigure 下的 jdbc 目录下,主要的自动配置类有:
- DataSourceAutoConfiguration
- 数据源相关配置:spring.datasource
- 容器中没有DataSource才会自动配置
- 本场景会自动配置 HikariDataSource
- DataSourceTransactionManagerAutoConfiguration: 事务管理器的自动配置
- JdbcTemplateAutoConfiguration: JdbcTemplate的自动配置,可以来对数据库进行crud
- 配置项@ConfigurationProperties(prefix = "spring.jdbc")
- @Bean@Primary JdbcTemplate;容器中有这个组件
- JndiDataSourceAutoConfiguration: jndi的自动配置
- XADataSourceAutoConfiguration: 分布式事务相关的
Datasource配置
| |
单测
| |
Druid数据源
官方Github地址:https://github.com/alibaba/druid
整合第三方技术的两种方式
- 自定义
- starter
自定义使用
导入依赖
| |
数据源配置
| |
转成config配置
| |
StatViewServlet
StatViewServlet的用途包括:
- 提供监控信息展示的html页面
- 提供监控信息的JSON API xml配置:
| |
转成config配置:
| |
StatFilter
用于统计监控信息;如SQL监控、URI监控
xml配置:
| |
转成config配置:
| |
starter使用
引入依赖
| |
自动配置
- 扩展配置项 spring.datasource.druid
- DruidSpringAopConfiguration.class, 监控SpringBean的;配置项:spring.datasource.druid.aop-patterns
- DruidStatViewServletConfiguration.class, 监控页的配置:spring.datasource.druid.stat-view-servlet;默认开启
- DruidWebStatFilterConfiguration.class, web监控配置;spring.datasource.druid.web-stat-filter;默认开启
- DruidFilterConfiguration.class}) 所有Druid自己filter的配置
配置示例
Expand/Collapse Code Block
| |
Mybatis整合
官方地址:https://github.com/mybatis
springboot官方starter:spring-boot-starter-*
第三方starter:xx-spring-boot-starter
在 github 中自行寻找
| |
配置模式
全局配置文件:
- SqlSessionFactory: 自动配置
- SqlSession:自动配置了 SqlSessionTemplate 组合了SqlSession
- @Import(AutoConfiguredMapperScannerRegistrar.class);
- Mapper: 只要操作MyBatis接口标注了 @Mapper 就会被自动扫描进来
1 2 3 4 5 6 7 8 9@org.springframework.context.annotation.Configuration @ConditionalOnClass({ SqlSessionFactory.class, SqlSessionFactoryBean.class }) @ConditionalOnSingleCandidate(DataSource.class) @EnableConfigurationProperties(MybatisProperties.class) @AutoConfigureAfter({ DataSourceAutoConfiguration.class, MybatisLanguageDriverAutoConfiguration.class }) public class MybatisAutoConfiguration implements InitializingBean {} @ConfigurationProperties(prefix = MybatisProperties.MYBATIS_PREFIX) public class MybatisProperties {}
配置mybatis规则
application.yml文件中配置:
| |
查看 MybatisProperties 代码可以发现其中有个 private Configuration configuration。那么mybatis.configuration就是相当于改mybatis全局配置文件中的值。即不配置 mybatis.config-location 指定全局配置文件的内容。(注意:不要同时存在)
书写代码:controller->service->mapper->xml
注解模式
| |
混合模式
| |
mapper.xml
| |
最佳实战
- 引入 starter
- 配置application.yml
- 编写 mapper 接口并标注 @Mapper 注解
- 简单方法直接使用注解方式
- 复杂方法编写 mapper.xml 进行绑定映射
- @MapperScan("com.chnherb.boot.mapper") 简化,其他接口可以不使用 @Mapper 注解标注
整合MyBatis-Plus
MyBatis-Plus简介
MyBatis-Plus 是一个 MyBatis 增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。
mybatis plus 官网,建议安装 MybatisX插件。
引入依赖
| |
自动配置
自动配置
MybatisPlusAutoConfiguration 配置类,MybatisPlusProperties 配置项绑定。mybatis-plus:xxx 就是对mybatis-plus的定制
SqlSessionFactory 自动配置好。底层是容器中默认的数据源
mapperLocations 自动配置好的。有默认值。classpath*:/mapper/**/*.xml;任意包的类路径下的所有mapper文件夹下任意路径下的所有xml都是sql映射文件。 建议以后sql映射文件,放在 mapper下
容器中也自动配置好了 SqlSessionTemplate
@Mapper 标注的接口也会被自动扫描;建议直接 @MapperScan("com.atguigu.admin.mapper") 批量扫描就行 优点:
只需要Mapper继承 BaseMapper 就可以拥有CRUD能力
5.5.2.11 - SpringBoot-05单元测试
JUnit5
简介
Spring Boot 2.2.0 版本开始引入 JUnit 5 作为单元测试默认库
作为最新版本的JUnit框架,JUnit5与之前版本的Junit框架有很大的不同。JUnit5旨在发展成一个测试平台,而非仅仅一个测试框架工具,由三个不同子项目的几个不同模块组成。
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
JUnit Platform: Junit Platform是在JVM上启动测试框架的基础,不仅支持Junit自制的测试引擎,其他测试引擎也都可以接入。
JUnit Jupiter: JUnit Jupiter提供了JUnit5的新的编程模型,是JUnit5新特性的核心。内部 包含了一个测试引擎,用于在Junit Platform上运行。
JUnit Vintage: 由于JUint已经发展多年,为了照顾老的项目,JUnit Vintage提供了兼容JUnit4.x,Junit3.x的测试引擎。
注意:
SpringBoot 2.4 以上版本移除了默认对 Vintage 的依赖。如果需要兼容junit4需要自行引入(不能使用junit4的功能 @Test)
JUnit 5’s Vintage Engine Removed from spring-boot-starter-test,如果需要继续兼容junit4需要自行引入vintage
| |
引入依赖
| |
使用
| |
以前的用法: @SpringBootTest + @RunWith(SpringTest.class)
SpringBoot整合Junit以后。
- 编写测试方法:@Test标注(注意需要使用junit5版本的注解)
- Junit类具有Spring的功能,@Autowired、比如 @Transactional 标注测试方法,测试完成后自动回滚
JUnit5常用注解
JUnit5的注解与JUnit4的注解有所变化
https://junit.org/junit5/docs/current/user-guide/#writing-tests-annotations
- @Test :表示方法是测试方法。但是与JUnit4的@Test不同,他的职责非常单一不能声明任何属性,拓展的测试将会由Jupiter提供额外测试
- @ParameterizedTest :表示方法是参数化测试,下方会有详细介绍
- @RepeatedTest :表示方法可重复执行,下方会有详细介绍
- @DisplayName :为测试类或者测试方法设置展示名称
- @BeforeEach :表示在每个单元测试之前执行
- @AfterEach :表示在每个单元测试之后执行
- @BeforeAll :表示在所有单元测试之前执行
- @AfterAll :表示在所有单元测试之后执行
- @Tag :表示单元测试类别,类似于JUnit4中的@Categories
- @Disabled :表示测试类或测试方法不执行,类似于JUnit4中的@Ignore
- @Timeout :表示测试方法运行如果超过了指定时间将会返回错误
- @ExtendWith :为测试类或测试方法提供扩展类引用
断言
断言(assertions)是测试方法中的核心部分,用来对测试需要满足的条件进行验证。这些断言方法都是 org.junit.jupiter.api.Assertions 的静态方法。用来检查业务逻辑返回的数据是否合理。所有的测试运行结束以后,会有一个详细的测试报告。JUnit 5 内置的断言可以分成如下几个类别。
简单断言
用来对单个值进行简单的验证。如:
| 方法 | 说明 |
|---|---|
| assertEquals | 判断两个对象或两个原始类型是否相等 |
| assertNotEquals | 判断两个对象或两个原始类型是否不相等 |
| assertSame | 判断两个对象引用是否指向同一个对象 |
| assertNotSame | 判断两个对象引用是否指向不同的对象 |
| assertTrue | 判断给定的布尔值是否为 true |
| assertFalse | 判断给定的布尔值是否为 false |
| assertNull | 判断给定的对象引用是否为 null |
| assertNotNull | 判断给定的对象引用是否不为 null |
数组断言
通过 assertArrayEquals 方法来判断两个对象或原始类型的数组是否相等
| |
组合断言
assertAll 方法接受多个 org.junit.jupiter.api.Executable 函数式接口的实例作为要验证的断言,可以通过 lambda 表达式很容易的提供这些断言
| |
异常断言
JUnit4想要测试方法的异常情况时,需要用@Rule注解的ExpectedException变量,比较麻烦。而JUnit5提供了一种新的断言方式Assertions.assertThrows() ,配合函数式编程就可以进行使用。
| |
超时断言
Assertions.assertTimeout() 为测试方法设置了超时时间
| |
快速失败
通过 fail 方法直接使得测试失败
| |
前置条件
JUnit 5 中的前置条件(assumptions【假设】)类似于断言,不同之处在于不满足的断言会使得测试方法失败,而不满足的前置条件只会使得测试方法的执行终止。前置条件可以看成是测试方法执行的前提,当该前提不满足时,就没有继续执行的必要。
Expand/Collapse Code Block
| |
assumeTrue 和 assumFalse 确保给定的条件为 true 或 false,不满足条件会使得测试执行终止。assumingThat 的参数是表示条件的布尔值和对应的 Executable 接口的实现对象。只有条件满足时,Executable 对象才会被执行;当条件不满足时,测试执行并不会终止。
嵌套测试
JUnit 5 可以通过 Java 中的内部类和@Nested 注解实现嵌套测试,从而可以更好地把相关的测试方法组织在一起。在内部类中可以使用@BeforeEach 和@AfterEach 注解,且嵌套的层次没有限制。
Expand/Collapse Code Block
| |
参数化测试
参数化测试是JUnit5很重要的一个新特性,它使得用不同的参数多次运行测试成为了可能,也为单元测试带来许多便利。
利用@ValueSource等注解指定入参,可以使用不同的参数进行多次单元测试,而不需要每新增一个参数就新增一个单元测试,省去了很多冗余代码。
@ValueSource: 为参数化测试指定入参来源,支持八大基础类以及String类型,Class类型
@NullSource: 表示为参数化测试提供一个null的入参
@EnumSource: 表示为参数化测试提供一个枚举入参
@CsvFileSource:表示读取指定CSV文件内容作为参数化测试入参
@MethodSource:表示读取指定方法的返回值作为参数化测试入参(注意方法返回需要是一个流)
如果参数化测试仅仅只能做到指定普通的入参还达不到让人惊艳的地步。它的强大之处还在于可以支持外部的各类入参。如:CSV,YML,JSON 文件甚至方法的返回值也可以作为入参。只需要去实现ArgumentsProvider接口,任何外部文件都可以作为它的入参。
Expand/Collapse Code Block
| |
迁移指南
迁移时需要注意如下的变化:
- 注解在 org.junit.jupiter.api 包中,断言在 org.junit.jupiter.api.Assertions 类中,前置条件在 org.junit.jupiter.api.Assumptions 类中。
- 把@Before 和@After 替换成@BeforeEach 和@AfterEach。
- 把@BeforeClass 和@AfterClass 替换成@BeforeAll 和@AfterAll。
- 把@Ignore 替换成@Disabled。
- 把@Category 替换成@Tag。
- 把@RunWith、@Rule 和@ClassRule 替换成@ExtendWith。
5.5.2.12 - SpringBoot-06指标监控
SpringBoot Actuator
简介
未来每一个微服务在云上部署以后,我们都需要对其进行监控、追踪、审计、控制等。SpringBoot就抽取了Actuator场景,使得每个微服务快速引用即可获得生产级别的应用监控、审计等功能。
官方文档:using.htm l# using.packaging-for-production -> actuator
1.x与2.x对比
SpringBoot 1.x引入的Actuator是1.x版本,2.x同理。
SpringBoot Actuator 1.x:
支持SpringMVC
基于继承方式进行扩展
层级Metrics配置
自定义Metrics收集
默认较少的安全策略 SpringBoot Actuator 2.x:
支持SpringMVC、JAX-RS以及Webflux
注解驱动进行扩展
层级&名称空间Metrics
底层使用MicroMeter,强大、便捷
默认丰富的安全策略
使用
导入依赖包:
| |
配置:
| |
Endpoints
具体 Endpoints 见官方文档:actuator # actuator.endpoints
http://localhost:8888/actuator/beans:所有注入bean
http://localhost:8888/actuator/conditions:positiveMatches(开启的条件);negativeMatches(未开启的条件);
http://localhost:8888/actuator/configprops:生效的配置属性值
http://localhost:8888/actuator/env:环境变量
语法:
http://localhost:8888/actuator/{endpointName}/detailPath
常用的Endpoint:
- Health:健康检查
- Metrics:运行时指标
- Loggers:日志记录
可视化
https://github.com/codecentric/spring-boot-admin
Actuator Endpoint
Health Endpoint
健康检查端点,一般用于在云平台,平台会定时的检查应用的健康状况,需要Health Endpoint可以为平台返回当前应用的一系列组件健康状况的集合。
注意:
- health endpoint返回的结果,应该是一系列健康检查后的一个汇总报告
- 很多健康检查默认已经自动配置,比如:数据库、redis等
- 比较容易添加自定义的健康检查机制
Metrics Endpoint
提供详细的、层级的、空间指标信息,这些信息可以被pull(主动推送)或者push(被动获取)方式得到:
- 通过Metrics对接多种监控系统
- 简化核心Metrics开发
- 添加自定义Metrics或者扩展已有Metrics
管理Endpoints
开启与禁用Endpoints
默认所有的Endpoint除过shutdown都是开启的。
需要开启或者禁用某个Endpoint。配置模式为 management.endpoint.
.enabled = true 1 2 3 4management: endpoint: beans: enabled: true或者禁用所有的Endpoint然后手动开启指定的Endpoint
1 2 3 4 5 6 7 8management: endpoints: enabled-by-default: false endpoint: beans: enabled: true health: enabled: true
暴露Endpoints
支持的暴露方式
- HTTP:默认只暴露health和info Endpoint
- JMX:默认暴露所有Endpoint
- 除过health和info,剩下的Endpoint都应该进行保护访问。如果引入SpringSecurity,则会默认配置安全访问规则
JMX方式
Java消息服务,简单的说就是java 提供了一批消息发送的接口,通过JMS提供的接口, 可以实现不同系统之间消息的发送。
actuator 默认使用 JMS 方式暴露了 endPoints,但没有通过 Web 的方式。具体可见:actuator # actuator.endpoints.exposing
使用方法:
- 启动应用程序
- 终端输入 jconsole 命令
- 选择对应的应用程序
定制EndPoint
定制Health
Expand/Collapse Code Block
| |
yaml配置:
| |
定制Info
一般常用两种方式。
配置文件
| |
实现InfoContributor
| |
定制Metrics
SpringBoot自动适配Metrics
- JVM metrics, report utilization of:
- Various memory and buffer pools
- Statistics related to garbage collection
- Threads utilization
- Number of classes loaded/unloaded
- CPU metrics
- File descriptor metrics
- Kafka consumer and producer metrics
- Log4j2 metrics: record the number of events logged to Log4j2 at each level
- Logback metrics: record the number of events logged to Logback at each level
- Uptime metrics: report a gauge for uptime and a fixed gauge representing the application’s absolute start time
- Tomcat metrics (server.tomcat.mbeanregistry.enabled must be set to true for all Tomcat metrics to be registered)
- Spring Integration metrics
增加定制Metrics
目的:增加业务打点,如接口方法访问次数等。
方法一:在业务代码中注册 MeterRegistry 并调用相应方法。
| |
方法二:直接注入 MeterBinder 的Bean
| |
使用方法:
- 访问 /actuator/metrics 可以发现多了一个 cityService.getCityByID.count 指标
- 访问 /actuator/metrics/cityService.getCityByID.count 可以看到 COUNT 为0
- 调用 /car?id=1,访问上一步的连接,可以看到 COUNT 数值
定制Endpoint
场景:开发ReadinessEndpoint来管理程序是否就绪,或者LivenessEndpoint来管理程序是否存活。如Kubernetes Probes。可参考:actuator # actuator.endpoints.kubernetes-probes
代码如下:
| |
使用方式:
- 读操作
- web 访问 /actuator/myservice
- jconsole 打开界面选择 MBeans -> {应用} -> Endpoint -> Myservice -> Operations -> myServiceInfo
- 写操作
- jconsole 打开界面选择 MBeans -> {应用} -> Endpoint -> Myservice -> Operations -> stopMyService(可以看到应用程序控制台输出内容)
5.5.2.13 - SpringBoot-07配置与加载过程
Profile
application-profile
为了方便多个环境适配,SpringBoot简化了 profile 功能。
Profile条件装配
- 默认配置文件 application.yaml;任何时候都会加载
- 指定环境配置文件 application-{env}.yaml
- 激活指定环境
- 配置文件激活
- 命令行激活:java -jar xxx.jar --spring.profiles.active=prod --person.name=haha
- 修改配置文件的任意值,命令行优先
- 默认配置与环境配置同时生效
- 同名配置项,profile配置优先
Profile分组
参考官方文档:features # features.profiles
| |
配置加载
官方文档:features # features.external-config
外部配置源
常用:Java属性文件、YAML文件、环境变量、命令行参数;
配置文件路径
- classpath 根路径
- classpath 根路径下的 config 目录
- jar 包当前目录
- jar 包当前目录的 config 目录
- /config 子目录的直接子目录
配置文件加载顺序
- 当前 jar 包内部的 application.properites 和 application.yml
- 当前 jar 包内部的 application-{profile}.properties 和 application-{profile}.yml
- 引用的外部 jar 包的 application.properties 和 application.yml
- 引用的外部 jar 包的 application-{profile}.properties 和 application-{profile}.yml 指定环境优先,外部优先,后面的可以覆盖前面的同名配置项。
自定义starter
官方文档:using # using.build-systems.starters
starter启动原理
- starter-pom 引入 autoconfigurer 包
- autoconfigurer 包中配置使用 META-INF/spring.factories 中 EnableAutoConfiguration 的值,使得项目启动加载指定的自动配置类
- 编写自动配置类 xxAutoConfiguration -> xxProperties starter模块:
自定义starter
xxx-spring-boot-starter(启动器)
xxx-spring-boot-starter-autoconfigure(自动配置包)
SpringBoot原理
SpringBoot启动过程
- 创建 SpringApplication
- 保存一些信息。
- 判定当前应用的类型:ClassUtils、Servlet
- bootstrappers:初始启动引导器(List
):去spring.factories文件中找 org.springframework.boot.Bootstrapper - 找 ApplicationContextInitializer;spring.factories 中找 ApplicationContextInitializer
- List<ApplicationContextInitializer> initializers
- 找 ApplicationListener 应用监听器。spring.factories 中找 ApplicationListener
- List<ApplicationListener> listeners
- 运行 SpringApplication
- StopWatch
- 记录应用的启动时间
- 创建引导上下文(Context环境)createBootstrapContext()
- 获取到所有之前的 bootstrappers 挨个执行 intitialize() 来完成对引导启动器上下文环境设置
- 让当前应用进入headless模式。java.awt.headless
- 获取所有 RunListener(运行监听器)【为了方便所有Listener进行事件感知】
- getSpringFactoriesInstances 去spring.factories找 SpringApplicationRunListener.
- 遍历 SpringApplicationRunListener 调用 starting 方法;(相当于通知所有系统项目正在启动。)
- 保存命令行参数;ApplicationArguments
- 准备环境 prepareEnvironment();
- 返回或者创建基础环境信息对象。StandardServletEnvironment
- 配置环境信息对象。(读取所有的配置源的配置属性值。)
- 绑定环境信息
- 监听器调用 listener.environmentPrepared();通知所有的监听器当前环境准备完成
- 创建IOC容器(createApplicationContext)
- 根据项目类型(Servlet)创建容器,
- 当前会创建 AnnotationConfigServletWebServerApplicationContext
- 准备ApplicationContext IOC容器的基本信息 prepareContext()
- 保存环境信息
- IOC容器的后置处理流程
- 应用初始化器 applyInitializers
- 遍历所有的 ApplicationContextInitializer 。调用 initialize.。来对ioc容器进行初始化扩展功能
- 遍历所有的 listener 调用 contextPrepared。EventPublishRunListenr;通知所有的监听器contextPrepared
- 所有的监听器 调用 contextLoaded。通知所有的监听器 contextLoaded;
- 刷新IOC容器。refreshContext
- 创建容器中的所有组件(Spring注解)
- 容器刷新完成后工作 afterRefresh
- 所有监听 器 调用 listeners.started(context); 通知所有的监听器 started
- 调用所有runners;callRunners()
- 获取容器中的 ApplicationRunner
- 获取容器中的 CommandLineRunner
- 合并所有runner并且按照@Order进行排序
- 遍历所有的runner。调用 run 方法
- 如果以上有异常,调用Listener 的 failed
- 调用所有监听器的 running 方法 listeners.running(context),通知所有的监听器 running
- running如果有问题继续通知 failed。调用所有 Listener 的 failed,通知所有的监听器 failed
Application Events and Listeners
官方文档:spring-boot-features # boot-features-application-events-and-listeners
ApplicationContextInitializer
ApplicationListener
SpringApplicationRunListener
ApplicationRunner与CommandLineRunner
5.5.3 - SpringMVC
Introduction
SpringMVC
5.6 - 第三方包
Introduction
Java第三方包
5.6.1 - log
背景
日志出现的顺序:
log4j -> JUL -> JCL -> slf4j -> logback -> log4j2
基本概念
日志接口
java common logging
SLF4J(Simple Logging Facade for Java)
日志实现
log4j
java.util.logging
simplelog
LogBack
log4j2

图片来自官网 http://slf4j.org/manual.html
jar包简介
Expand/Collapse Code Block
| |
注意:如果slf4j绑定了多个实现日志,默认使用第一个
桥接器
初期代码中使用的是具体日志实现如log4j或者jcl,后期想修改成slf4j+xx,那么无需修改代码,使用桥接器即可做到
使用桥接器注意问题
1、jcl-over-sIf4j.jar和slf4j-jcl.jar不能同时部署。前一个jar文件将导致JCL将日志系统的选择始托给SLF4J,后一个jar文件将导致SLF4J将日志系统的选择委托给JCL,从而导致无益循环。
2、log4j-overI-slf4j.jar(桥接器)和sif4j-log4j12.jar(适配器)不能同时出现。(死循环)
3、jul-to-sIf4j.jar(桥接器)和sIf4j-jdk14.jar(适配器)不能同时出现
4、所有的桥接器都只对Logger日志记录器对象有效,如果程序中调用了内部的配置类或者是Appender,Filter等对象,将无法产生效果。
其中2、3点死循环原因如下图:(报错:栈内存溢出StackOverflowError!!)

SLF4J
slf4j中适配器原理
使用slf4j的日志绑定流程:
1、添加sIf4j-api的依赖
2、使用sIf4j的API在项目中进行统一的日志记录
3、绑定具体的日志实现框架
3.1 绑定已经实现了sIf4j的日志框架,直接添加对应依赖
3.2 绑定没有实现slf4j的日志框架,先添加日志的适配器,再添加实现类的依赖
4、sIf4j有且仅有一个日志实现框架的绑定(如果出现多个默认使用第一个依赖日志实现)
slf4j自动绑定实现类的方式基于约定:它要求你在实现类的包名和拿到LoggerFactory实例的文件路径必须是:org/slf4j/impl/StaticLoggerBinder.class
LogBack
logback入门
Logback 主要由三个模块组成:
- logback-core:其它两个模块的基础模块
- logback-classic:log4j的一个改良版本,同时完整实现了slf4j API
- logback-access:访问模块与Servlet容器集成提供通过Http来访问日志的功能 其中 logback-core 提供了 LogBack 的核心功能,是另外两个组件的基础。logback-classic 的地位和作用等同于 Log4J,它也被认为是 Log4J 的一个改进版,并且它实现了简单日志门面 SLF4J,所以当想配合 SLF4J 使用时,需要将 logback-classic 加入 classpath;而 logback-access 主要作为一个与 Servlet 容器交互的模块,比如说 tomcat 或者 jetty,提供一些与 HTTP 访问相关的功能。
logback配置
logback会一次读取以下类型配置文件:
- logback.groovy
- logback-test.xml
- logback.xml 如果均不存在会采用默认配置
1、logback组件之间的关系
1.1 Logger:日志的记录器,把它关联到到应用的对应的context上后,主要用于存放日志对象,也可以定义日志类型、级别
1.2 Appender:用于指定日志输出的目的地,目的地可以是控制台、文件、数据库的等等。
1.3 Layout:负责把时间转换成字符串,格式化的日志信息的输出。在logback中Layout对象被封装在encoder中。
2、FileAppender
Expand/Collapse Code Block
| |
3、logback-access的使用
logback-access模块与Servlet容器(如Tomcat和Jetty)集成,以提供HTTP访问日志功能。我们可以使用logback-access模块来替换tomcat的访问日志
1)将logback-access.jar与logback-core.jar复制到%TOMCAT_HOME/lib/目录下
2)修改$TOMCAT_HOME/conf/server.xml中的Host元素中添加:
| |
3)logback默认会在$TOMCAT_HOME/conf下查找文件logback-access.xml

logback使用步骤
1、jar包
Expand/Collapse Code Block
| |
2、logback.xml 在 src 根目录下建立 logback.xml
Expand/Collapse Code Block
| |
3、输出日志代码
| |
Log4j
log4使用步骤
1、jar包
Expand/Collapse Code Block
| |
2、log4j.properties
Expand/Collapse Code Block
| |
Log4j2
ApacheLog4j2是对Log4j的升级版,参考了logback的一些优秀的设计,并且修复了一些问题,因此带来了一些重大的提升,主要有:
1、异常处理 :在logback中,Appender中的异常不会被应用感知到,但是在log84j2中,提供了一些异常处理机制。
2、性能提升:log4j2相较于log4j和logback都具有很明显的性能提升,后面会有官方测试的数据。
3、自动重载配置:参考了logback的设计,当然会提供自动刷新参数配置,最实用的就是我们在生产上可以动态的修改日志的级别而不需要重启应用。
4、无垃圾机制:log4j2在大部分情况下,都可以使用其设计的一套无垃圾机制,避免频繁的日志收集导致的jvm gc。
日志实现方式
Log4j2提供了两种实现日志的方式,一个是通过AsyncAppender(一般性能低不使用),一个是通过AsyncLogger,分别对应我们说的Appender组件和Logger组件。
注意:配置异步日志需要添加依赖
| |
AsyncAppender方式
Expand/Collapse Code Block
| |
AsyncLogger方式
AsyncLogger才是log4j2的重头戏,也是官方推荐的异步方式。它可以使得调用Logger.log返回的更快。你可以有两种选择:全局异步和混合异步。
全局异步
所有的日志都异步的记录,在配置文件上不用做任何改动,只需要添加个log4j2.component.properties配置(Resources路径下);
| |
混合异步
可以在应用中同时使用同步日志和异步日志,这使得日志的配置方式更加灵活。
Expand/Collapse Code Block
| |

使用异步日志需要注意的问题:
1、如果使用异步日志,AsyncAppender、AsyncLogger和全局目志,不要同时出现。性能会和
AsyncAppend一致,降至最低。(木桶原理)
2、设置includeLocation=false,否则打印位置信息会急剧降低异步日志的性能,比同步日志还要慢。
无垃圾记录
垃圾收集暂停是延迟峰值的常见原因,并且对于许多系统而言,花费大量精力来控制这些暂停。
许多日志库(包括以前版本的Log4j)在稳态日志记录期间分配临时对象,如日志事件对象,字符串,字符数组,字节数组等。这会对垃圾收集器造成压力并增加GC暂停发生的频率。
从版本2.6开始,默认情况下Log4j以“无垃圾“模式运行,其中重用对象和缓冲区,平且尽可能不分配临时对象。还有一个“低垃圾“模式,它不是完全无垃圾,但不使用ThreadLocal字段。
Log4j 2.6中的无垃圾日志记录部分通过重用ThreadLoca|字段中的对象来实现,部分通过在将文本转换为字节时重用缓冲区来实现。
有两个单独的系统厘性可用于手动控制Log4j用于邀免创建临时对象的机制:
- log4j2.enableThreadlocals - 如果"true"(非Web应用程序的默认值)对象存储ThreadLocal字段中并重新使用,否则将为每个日志事件创建新对象。
- log4j2.enablepirectEncoders - 如果将"true"(默认)日志事件转换为文本,则将此文本转换为字节而不创建临时对象。注意:由于共享缓冲区上的同步,在此模式下多线程应用程序的同步日志记录性能可能更差。如果您的应用程序是多线程的并且日志记录性能很重要,请考虑使用异步记录器。
log4j2使用步骤
1、jar包
Expand/Collapse Code Block
| |
2、log2j 配置文件:log4j2.xml
Expand/Collapse Code Block
| |
3、输出日志代码
| |
对比
核心jar包
log4j只需要引用一个包 log4j.log4j
log4j2需要2个核心包:org.apache.logging.log4j.log4j-api 和 org.apache.logging.log4j.log4j-core,并且路径不同
logger调用
| |
JCL
1、动态加载
通过LogFactory动态加载Log实现类

2、支持日志
日志门面支持的日志实现数组
| |
缺陷:只考虑了主流的日志框架
3、日志实现
获取具体的日志实现
| |
JCL(java common logging)+Log4j
1、jar包
| |
2、配置 common-logging.properties 文件 只需要一行即可,放在 classpath 下,如果是 Maven 中就在 src/resources 下,不过如果没有 common-logging.properties 文件,但是 src 下有 log4j.properties 配置也可以正常的输出 Log4j 设置的日志。
| |
3、配置log4j.properties 略
4、输出日志代码
| |
JCL与SLF4J比较

Spring boot日志
1、日志结构

总结:
1、springboot底层默认使用logback作为日志实现。
2、ERTSLEDBIE
3、将JUL也转换成sIf4j
4、也可以使用log4j2作为日志门面,但是最终也是通过sIf4j调用logback
2、消息格式
指定控制台输出消息格式


3、指定配置
给类路径下放上每个日志框架自己的配置文件;SpringBoot就不使用默认配置的了
| 日志框架 | 配置文件 |
|---|---|
| Logback | logback-spring.xml |
| Log4j2 | log4j2-spring.xml,log4j2.xml |
| JUL | logging.properties |
logbackxml:直接就被日志框架识别了
4、解析日志配置
使用SpringBoot解析日志配置
logback-spring.xml:由SpringBoot解析日志配置
| |
application.properties
| |
另参考:springboot不同环境日志:https://springboot.io/t/topic/757
| |
5、日志切换为log4j2
| |
Reference
5.6.2 - lombok功能及原理
概述
lombok官网:https://projectlombok.org
使用
1、IDE安装lombok插件
2、项目引入依赖
| |
功能
@Data
注解在类上,相当于同时使用了@ToString、@EqualsAndHashCode、@Getter、@Setter和@RequiredArgsConstrutor这些注解,对于POJO类十分有用
@Value
用在类上,是@Data的不可变形式,相当于为属性添加final声明,只提供getter方法,而不提供setter方法
@Builder
用在类、构造器、方法上,为你提供复杂的builder APIs,让你可以像如下方式一样调用Person.builder().name("Adam Savage").city("San Francisco").job("Mythbusters").job("Unchained Reaction").build();更多说明参考Builder
@Log
根据不同的注解生成不同类型的log对象,但是实例名称都是log,有六种可选实现类
@Slf4j
自动引入log对象
@Getter/@Setter
用在属性上,再也不用自己手写setter和getter方法了,还可以指定访问范围
@NonNull
给方法参数增加这个注解会自动在方法内对该参数进行是否为空的校验,如果为空,则抛出NPE(NullPointerException)
@Cleanup
自动管理资源,用在局部变量之前,在当前变量范围内即将执行完毕退出之前会自动清理资源,自动生成try-finally这样的代码来关闭流
原理
java注解
从JDK5开始,Java增加对元数据(描述数据属性的信息)的支持,也就是注解,可以把注解理解为代码里的特殊标记,这些标记可以在编译,类加载,运行时被读取,并执行相应的处理。通过注解开发人员可以在不改变原有代码和逻辑的情况下在源代码中嵌入补充信息。
注解解析方式
JDK5引入了注解的同时,也提供了两种解析方式。
1、运行时解析
2、编译时解析
运行时解析
运行时能够解析的注解,必须将@Retention设置为RUNTIME,这样就可以通过反射拿到该注解。java.lang,reflect反射包中提供了一个接口AnnotatedElement,该接口定义了获取注解信息的几个方法,Class、Constructor、Field、Method、Package等.
编译时解析
编译时解析有两种机制:
1、Annotation Processing Tool
2、Pluggable Annotation Processing API:插入式注解处理器
APT自JDK5产生,JDK7已标记为过期,不推荐使用,JDK8中已彻底删除。新的解析机制为 JSR 269规范(可插拔批注处理API)。
在JAVA 1.6后,JDK提供了一种方式,可以让我们修改编译过程,在编译期融入我们自己编译逻辑,也就是插入式注解处理器,它提供了一组编译器的插入式注解处理器的标准API在编译期间对注解进行处理。解决了APT没有集成到javac中,只能在运行时通过反射来获取注解值,运行时代码效率降低等问题。
javac的编译过程,大致可以分为3个过程,分别是:
1、解析与填充符号表过程
读取命令行上指定的所有源文件,将其解析为语法树,然后将所有外部可见的定义输入到编译器的符号表中。
2、插入式注解处理器的注解处理过程
调用所有适当的注解处理器。如果任何注解处理器生成任何新的源文件或类文件,则将重新启动编译,直到没有新文件创建为止。
3、分析与字节码生成过程
最后,分析器创建的语法树将被分析并转换为类文件。在分析过程中,可能会找到对其他类的引用。编译器将检查这些类的源和类路径。如果在源路径上找到它们,则这些文件也将被编译,尽管它们将不受注解处理。

自定义支持JSR269
一般javac的编译过程,java文件首先通过进行解析构建出一个AST,然后执行注解处理,最后经过分析优化生成二进制的.class文件。我们能做到的是,在注解处理阶段进行一些相应处理。

定义注解
| |
Retention 注解上面有一个属性value,它是RetentionPolicy类型的枚举类,RetentionPolicy枚举类中有三个值。
| |
Target 注解上面也有个属性value,它是ElementType类型的枚举。是用来修饰此注解作用在哪的。
| |
定义注解处理器
定义注解处理器,需要继承AbstractProcessor 类。继承完以后基本的框架类型如下
| |
继承了父类的两个方法,方法描述如下 1、init方法:主要是获得编译时期的一些环境信息
2、process方法:在编译时,编译器执行的方法。也就是我们写具体逻辑的地方
编译
| |
验证
这里验证@Slf4j的log字段。步骤如下:
1、编写代码,引用@Slf4j并使用log变量,编译
2、将编译好的class反编译,会发现Slf4j会自动将注解转换成
| |
Reference
6 - Golang
Introduction
Golang
6.1 - go环境搭建及语法
go语言基础的环境搭建和语法
Go开发包网站
https://pkg.go.dev/unicode/utf8
基本概念
go root
golang 的安装路径,一般为/usr/local/go
go path
个人工作路径,如/Users/admin/go_project/
| |
go代理
网站:goproxy.io
| |
hello world
go_project // (go_project为GOPATH目录)
-- bin // golang编译可执行文件存放路径
-- pkg // golang编译包时,生成的.a文件存放路径
-- src // 源码路径。按照golang默认约定,go run,go install等命令的当前工作路径(即在此路径下执行上述命令)。
目录:/Users/bo/go_project/src/go_code/project01/main
创建hello.go
| |
创建mod
| |
运行程序
| |
编译程序
| |
格式化
| |
git下载包出现ssl问题:OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443 解决办法:取消http代理
| |
包导入
| |
绝对导入 相对导入
相对导入,有两点需要注意
- 项目不要放在
$GOPATH/src下,否则会报错(比如我修改当前项目目录为GOPATH后,运行就会报错) - Go Modules 不支持相对导入,在你开启 GO111MODULE 后,无法使用相对导入。 当我们导入一个包时,它会:
- 先从项目根目录的 xxx 目录中查找
- 最后从
$GOROOT/src目录下查找 - 然后从
$GOPATH/src目录下查找 - 都找不到的话,就报错。
你导入的包如果有域名,都会先在
$GOPATH/pkg/mod下查找,找不到就连网去该网站上寻找,找不到或者找到的不是一个包,则报错。
而如果你导入的包没有域名(比如 "fmt"这种),就只会到 $GOROOT 里查找。
go模块
go mod init创建了一个新的模块,初始化go.mod文件并且生成相应的描述go build, go test和其它构建代码包的命令,会在需要的时候在go.mod文件中添加新的依赖项go list -m all列出了当前模块所有的依赖项go get修改指定依赖项的版本(或者添加一个新的依赖项)go mod tidy移除模块中没有用到的依赖项。
当前目录/User/bo/hello,非$GOPATH
| |
go mod init
| |
启动go mod路径导入会报错
解决办法:
| |
注意如果还报错,重启下IDE即可,或者修改下文件,这只是IDE刷新的问题
go test
| |
go.mod文件只存在于模块的根目录中。模块子目录的代码包的导入路径等于模块根目录的导入路径(就是前面说的 module path)加上子目录的相对路径。比如,我们如果创建了一个子目录叫world,我们不需要(也不会想要)在子目录里面再运行一次go mod init了,这个代码包会被认为就是example.com/hello模块的一部分,而这个代码包的导入路径就是example.com/hello/world。 除了go.mod之外,go 命令行工具还维护了一个go.sum文件,它包含了指定的模块的版本内容的哈希值作为校验参考:
go list
把当前的模块和它所有的依赖项都列出来
| |
在上述
go list命令的输出中,当前的模块,又称为主模块 (main module),永远都在第一行,接着是主模块的依赖项,以依赖项的 module path 排序。
go get
go get会做两件事:
从远程下载需要用到的包
执行go install
| |
go install
go install 会生成可执行文件直接放到bin目录下,当然这是有前提的
你编译的是可执行文件,如果是一个普通的包,会被编译生成到pkg目录下该文件是.a结尾
go mod
清除这些没用到的依赖项
| |
下载依赖
| |
基础语法
1、golang的命名推荐使用驼峰命名法,必须以一个字母(Unicode字母)或下划线开头,后面可以跟任意数量的字母、数字或下划线。
2、golang中根据首字母的大小写来确定可以访问的权限。无论是方法名、常量、变量名还是结构体的名称,如果首字母大写,则可以被其他的包访问;如果首字母小写,则只能在本包中使用
3、结构体中属性名的大写
如果属性名小写则在数据解析(如json解析,或将结构体作为请求或访问参数)时无法解析
声明
变量的定义
| |
匿名变量
不需要用的变量都可以用_表示,可以多个,如:
| |
常量
在Golang的常量定义中,使用const关键字,并且不能使用:=标识符。
判断
循环
函数
defer
defer 语句会将函数推迟到外层函数返回之后执行。 推迟调用的函数其参数会立即求值,但直到外层函数返回前该函数都不会被调用。
注意,defer后面必须是函数调用语句,不能是其他语句,否则编译器会报错。
可以考虑到的场景是,文件的关闭,或数据库连接的释放等,这样打开和关闭的代码写在一起,既可以使得代码更加的整洁,也可以防止出现开发者在写了长长的业务代码后,忘记关闭的情况。
至于defer的底层实现,本文不进行详细的解释,简单来讲就是将defer语句后面的函数调用的地址压进一个栈中,在当前的函数执行完毕,CPU即将执行函数外的下一行代码之前,先把栈中的指令地址弹出给CPU执行,直到栈为空,才结束这个函数,继续执行后面的代码。
指针
但是,与 C 不同,Golang没有指针运算。
数组
| |
注意,在Golang中,数组的大小也同样和 C 语言一样不能改变。
切片
| |
Golang中的切片,不是拷贝,而是定义了新的指针,指向了原来数组所在的内存空间。所以,修改了切片数组的值,也就相应的修改了原数组的值了。 此外,切片可以用append增加元素。但是,如果此时底层数组容量不够,此时切片将会指向一个重新分配空间后进行拷贝的数组。
因此可以得出结论:
- 切片并不存储任何数据,它只是描述了底层数组中的一段。
- 更改切片的元素会修改其底层数组中对应的元素。
- 与它共享底层数组的切片都会观测到这些修改。
make
切片可以用内建函数 make 来创建,这也是你创建动态数组的方式。
两个定义,len(长度)和cap(容量):
len是数组的长度,指的是这个数组在定义的时候,所约定的长度。
cap是数组的容量,指的是底层数组的长度,也可以说是原数组在内存中的长度。
| |
进阶语法
结构体
在结构体中也遵循用大小写来设置公有或私有的规则。如果这个结构体名字的第一个字母是大写,则可以被其他包访问,否则,只能在包内进行访问。而结构体内的字段也一样,也是遵循一样的大小写确定可用性的规则。
定义
| |
声明
使用var关键字
| |
注意,在使用了var关键字之后不需要初始化,这和其他的语言有些不同。Golang会自动分配内存空间,并将该内存空间设置为默认的值,我们只需要按需进行赋值即可。
使用new函数
| |
使用字面量
| |
区别
第一种使用var声明的方式,返回的是该实例的结构类型,而第二第三种,返回的是一个指向这个结构类型的一个指针,是地址。
所以,对于第二第三种返回指针的声明形式,在我们需要修改他的值的时候,其实应该使用的方式是:
| |
但是,在Golang中,可以省略这一步骤,直接使用ming.name = "xiao wang"。尽管如此,我们应该知道这一行为的原因,分清楚自己所操作的对象究竟是什么类型,掌握这点对下面方法这一章节至关重要。
方法
label
break label
break的跳转标签(label)必须放在循环语句for循环前面,并且在break label跳出循环不再执行for循环里的代码。
goto label
goto label的label(标签)既可以定义在for循环前面,也可以定义在for循环后面,当跳转到标签地方时,继续执行标签下面的代码。
并发
线程与协程
进程和线程是由操作系统进行调度的,协程是对内核透明,由程序自己调度的。协程的切换一般由程序员在代码中显式控制,而不是交给操作系统去调度。它避免了上下文切换时的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。
goroutine
channel
| |
单向信道
默认情况下都是双向信道
可细分为只读信道和只写信道
只读信道
| |
只写信道
| |
仔细观察,区别在于 <- 符号在关键字 chan 的左边还是右边。
- <-chan 表示这个信道,只能从里发出数据,对于程序来说就是只读
- chan<- 表示这个信道,只能从外面接收数据,对于程序来说就是只写
有同学可能会问:为什么还要先声明一个双向信道,再定义单向通道呢?比如这样写
| |
因为信道肯定读和写都要有
range
Expand/Collapse Code Block
| |
select
WaitGroup
Reference
6.2 - Go语言基础知识01
背景&简介
历史背景
软件开发的新挑战:
1、多核硬件架构
2、超大规模分布式计算集群
3、Web 模式导致的前所未有的开发规模和更新速度
发展现状
语言特性
简单:
Go 语言足够简单,只有 25 个关键字。相比 C 语言 37 个关键字,C++ 语言 84 个关键字。
高效:
1)垃圾回收
2)指针
生产力:
只支持“复合”而非“继承”
云计算语言:
1)Docker
2)kubernetes
区块链语言:
1)ethereum(以太坊)
2)HYPERLEDGER
安装使用
下载安装
下载安装 Go 语言:
开发环境构建
GOPATH
1、在 1.8 版本前必须设置这个环境变量
2、1.8 版本后(含 1.8)如果没有设置使用默认值
1)在 Unix 上默认 $HOME/go
2)在 Windows 上默认为 %USERPROFILE%/go
3)在 Mac 上 GOPATH 可以通过修改 ~/.bash_profile 来设置
示例代码参考:https://github.com/chnherb/go-demo
运行程序
| |
注意,hello.go 必须 package main (go 中 package 和 目录不必一致),否则会报错“go run: cannot run non-main package”
编译程序
| |
main 函数
退出返回值
与其他主要编程语言的差异:
1、Go 中 main 函数不支持任何返回值
2、通过 os.Exit 来返回状态
| |
获取命令行参数
与其他主要编程语言的差异:
1、main 函数不支持传入参数
| |
2、在程序中直接通过 os.Args 获取命令行参数
| |
基本程序架构
变量&常量
快速设置连续值
| |
数据类型
基本数据类型
| |
类型转换
与其它主要编程语言的差异
1、Go 语言不允许隐式类型转换
2、别名和原有类型也不能进行隐式类型转换
| |
类型的预定义值
1、math.MaxInt64
2、math.MaxFloat64
3、math.MaxUint32
指针类型
1、不支持指针运算
2、string 是值类型,其默认的初始化值为空字符串,而不是 nil
| |
运算符
算数运算符
| 运算符 | 描述 | 示例 |
|---|---|---|
| + | 相加 | |
| - | 相减 | |
| * | 相乘 | |
| / | 相除 | |
| % | 求余 | |
| ++ | 自增 | |
| -- | 自减 |
Go 语言中没有前置 ++/--
比较运算符
如 ==、!= 、>、<、>=、<= 省略介绍
用 == 比较数组
与其它语言比较数组时比较引用不同,Go 比较的是数组中的值。
1、相同维度且含有相同个数元素的数组才可以比较
2、每个元素都相同的才相等
| |
逻辑运算符
&&、||、!、
位运算符
&、|、^、<<、>> 等
与其它语言主要差异:
&^ 按位清零
| |
条件和循环
循环
Go 语言只支持循环关键 for,且不需要括号
| |
if 条件
| |
与其它语言的差异: 1、condition 必须为布尔值
2、支持变量赋值:
| |
switch 条件
与其它语言的差异:
1、条件表达式不限制为常量或者整数;
2、单个 case 中,可以出现多个结果选项,使用逗号分隔;
3、与 C 语言等规则相反,Go 语言不需要用 break 来明确退出一个 case;
4、可以不设定 switch 之后的条件表达式,这种情况下,整个 switch 结构与多个 if...else... 的逻辑作用相同。
| |
常用集合
数组和切片
数组基础操作
| |
遍历
| |
数组截取
a[开始索引(包含), 结束索引(不包含), ]
| |
切片
切片内部结构

| |
切片共享存储结构

| |
数组 vs 切片
1、 容量是否可伸缩
数组不可伸缩,切片可以
2、是否可以进行比较
数组可以直接比较是否相等,切片不可以比较
Map
Map 基础操作
Map 声明
| |
与其它语言的区别: 在访问的 key 不存在时,会返回默认值,不能通过返回 nil 来判断元素是否存在。
| |
map遍历
| |
Map 与工厂模式
1、Map 的 value 可以是一个方法
2、与 Go 的 Dock type 接口方式一起,可以方便地实现单一方法对象的工厂模式
| |
实现 Set
Go 的内置集合中没有 Set 试下,可以 map[type]bool
1、元素的唯一性
2、基本操作
1)添加元素
2)判断元素是否存在
3)删除元素
4)元素个数
Expand/Collapse Code Block
| |
字符串
string 基本操作
与其它语言的主要差异:
1、string 是数据类型,不是引用或指针类型
2、string 是只读的 byte slice,len 函数可以它所包含的 byte 数
3、string 的 byte 数组可以存放任何数据
| |
Unicode UTF8
1、Unicode 是一种字符集(code point)
2、UTF8 是 unicode 的存储实现(转换为字节序列的规则)
| |
编码与存储
| 字符 | “中” |
|---|---|
| Unicode | 0x4E2D |
| UTF-8 | 0XE4B8AD |
| string/[]byte | [0xE4, 0xB8, 0xAD] |
常用字符串函数
1、string 包(https://pkg.go.dev/strings)
2、strconv 包(https://pkg.go.dev/strconv)
| |
函数
函数是一等公民
与其它语言的差异:
1、可以有多个返回值
2、所有参数都是值传递:slice,map,channel 会有传引用的错觉(可参考slice数据结构)
3、函数可以作为变量的值
4、函数可以作为参数和返回值
函数编程
计时函数
| |
学习函数式编程 推荐书籍:《计算机程序的构造和解释》
可变参数
| |
defer 函数
| |
面向对象编程
行为定义和实现
go是面向对象编程语言吗?https://go.dev/doc/faq#Is_Go_an_object-oriented_language
是也不是。
结构体定义
| |
实例创建及初始化
| |
行为(方法)定义
| |
Go 接口
与其它主要编程语言的差异:
1、接口为非入侵性,实现不依赖于接口定义(duck type)
2、接口的定义可以包含在接口使用者包内
扩展与复用
1、没有继承、重载、LSP(参数为父类、传入子类)等功能
2、只能通过匿名嵌套的方式
| |
多态
Expand/Collapse Code Block
| |
空接口与断言
1、空接口可以表示任何类型
2、通过断言来将空接口转换为指定类型
Expand/Collapse Code Block
| |
Go 接口最佳实践
1、倾向于使用晓得接口定义,很多接口只包含一个方法
| |
2、较大的接口定义,可以由多个小接口定义组合而成
| |
3、只依赖于必要功能的最小接口
| |
错误处理
Go 的错误机制
1、没有异常机制
2、error 类型实现了 error 接口
| |
3、可以通过 errors.New 来快速创建错误实例
| |
最佳实践
及早失败,避免嵌套
panic
panic vs os.Exit
1、os.Exit 退出时不会调用 defer 指定的函数
2、os.Exit 退出时不输出当前调用栈信息
recover
| |
当心 recover 成为恶魔
1、形成僵尸服务进程,导致 health check 失效
2、“Let it Crash!” 往往是恢复不确定性错误的最好方法
包和依赖管理
package
1、基本复用模块单元
以首字母大写来表明可被包外代码访问
2、代码的 package 可以和所在目录不一致
3、同一目录的 Go 代码的 package 要保持一致
其它注意项:
1、通过 go get 来获取远程依赖
go get -u 强制从网络更新远程依赖
2、注意代码在 GitHub 上的组织形式,以适应 go get
直接以代码路径开始,不要有 src
示例:https://github.com/easierway/concurrent_map
init 方法
1、在 main 被执行前,所有依赖的 package 的 init 方法都会被执行
2、不同包的 init 函数依照包导入的依赖关系决定执行顺序
3、每个包可以有多个 init 函数
4、包的每个源文件也可以有多个 init 函数,这点比较特殊
依赖管理
Go 未解决的依赖问题
1、同一环境下,不同项目使用同一包的不同版本
2、无法管理对包的特定版本的依赖
vendor 路径
随着 Go 1.5 release 版本的发布,vendor 目录被添加到除了 GOPATH 和 GOROOT 之外的依赖目录查找的解决方案。在 Go 1.6 之前,需要手动设置环境变量。
查找依赖包路径的解决方案如下:
1、当前包下的 vendor 目录
2、向上级目录查找,直到找到 src 下的 vendor 目录
3、在 GOPATH 下面查找依赖包
4、在 GOROOT 目录下查找
常用的依赖管理工具
godep:https://github.com/tools/godep
glide:https://github.com/Masterminds/glide
dep:https://github.com/golang/dep
测试
编写测试程序
1、源码文件以 _test 结尾:xxx_test.go
2、测试方法名以 Test 开头:func TestXXX(t *testing.T) {...} (或 Test_XXX)
单元测试
functions.go
| |
functions_test.go
| |
内置单元测试框架
1、Fail, Error:该测试失败,该测试继续,其他测试继续执行
2、FailNow, Fatal:该测试失败,该测试中止,其他测试继续执行
| |
代码覆盖率
| |
断言 https://github.com/stretchr/testify
| |
代码示例:
| |
tips:导入包报错,重启 Goland 即可。
Benchmark
benchmark_test.go
| |
运行测试
| |
BDD
Behavior Driven Development
Story Card
BDD in Go
项目网站:https://github.com/smartystreets/goconvey
安装:
| |
启动 Web UI
| |
使用示例:
| |
Reference
6.3 - Go语言基础知识02
并发编程
协程机制
Thread vs Groutine
1、创建时默认的 stack 的大小
1)JDK5 以后 Java Thread stack 默认为 1M
2)Groutine 的 Stack 初始化大小为 2K
2、和 KSE(Kernel Space Entity)的对应关系
1)Java Thread 是 1:1
2)Goroutine 是 M:N
共享内存并发机制
Mutex
| |
WaitGroup
| |
| |
CSP 并发机制
Communicating sequential processes
CSP vs Actor
1、和 Actor 直接通讯不同,CSP 模式则是通过 Channel 进行通讯的,更松耦合一些
2、Go 中 channel 是有容量限制并且独立于处理 Groutine,而如 Erlang、Actor 模式中的 mailbox 容量是无限的,接收进程也总是被动地处理消息
Expand/Collapse Code Block
| |
多路选择和超时
多渠道的选择
| |
超时控制
| |
channel 关闭和广播
1、想关闭的 channel 发送数据,会导致 panic
2、v, ok <- ch; ok 为 bool 值,true 表示正常接收,false 表示通道关闭。(通道关闭后如果取值会返回默认值)
3、所有的 channel 接收者都会在 channel 关闭时,立刻从阻塞等待中返回且上述 ok 值为 false。这个广播机制常被利用,进行多个订阅者同时发送信号。如:退出信号。
任务的取消
Expand/Collapse Code Block
| |
Context 与任务取消
Context
1、根 Context:通过 context.Background() 创建
2、子 Context:context.WithCancel(parentContext) 创建
ctx, cancel := context.WithCancel(context.Background())
3、当前 Context 被取消时,基于他的子 Context 都被会取消
4、接收取消通知 <- ctx.Done()
Expand/Collapse Code Block
| |
典型并发任务
只运行一次
Expand/Collapse Code Block
| |
仅需任意任务完成
Expand/Collapse Code Block
| |
所有任务完成
| |
对象池
Expand/Collapse Code Block
| |
sync.pool 对象缓存
sync.pool 对象获取:
1、尝试从私有对象(协程安全)获取
2、私有对象不存在,尝试从当前 Processor 的共享池(协程不安全)获取
3、如果当前 Processor 共享池也是空的,那么久尝试去其他 Processor 的共享池获取
sync.pool 对象放回:
1、如果私有对象不存在则保存为私有对象
2、如果私有对象存在,放入当前 Processor 子池的共享池中
使用方式:
| |
sync.Pool 对象的生命周期
1、GC 会清除 sync.pool 缓存的对象
2、对象的缓存有效期为下一次 GC 之前
使用示例:
Expand/Collapse Code Block
| |
sync.Pool 总结:
1、适合于通过复用,降低复杂对象的创建和 GC 代价
2、协程安全,会有锁的开销
3、生命周期受 GC 影响,不适合于做连接池等,需自己管理生命周期的资源的池化
反射
reflect.TypeOf vs reflect.ValueOf
1、reflect.TypeOf 返回类型(reflect.Type)
2、reflect.ValueOf 返回值(reflect.Value)
3、可以从 reflect.Value 获得类型
4、通过 Kind() 的来判断类型
利用反射编写灵活的代码
1、按名字访问结构的成员
| |
2、按名字访问结构的方法
| |
反射示例:
Expand/Collapse Code Block
| |
DeepEqual
比较切片和map
Expand/Collapse Code Block
| |
万能程序
Expand/Collapse Code Block
| |
unsafe
不安全行为
“不安全”行为的危险性
| |
合理转换
| |
原子类型操作
Expand/Collapse Code Block
| |
常见架构模式实现
pipe-filter framework
1、非常适合与数据处理及数据分析系统
2、Filter 封装数据处理的功能
3、松耦合:Filter 只跟数据(格式)耦合
4、Pipe 用于连接 Filter 传递数据或者在异步处理过程中缓冲数据流
进程内同步调用时,pipe 演变为数据在方法调用间传递
micro-kernel framework
特点
1)易于扩展
2)错误隔离
3)保持架构一致性
要点
1)内核包含公共流程或通用逻辑
2)将可变或可扩展部分规划为扩展点
3)抽象扩展点行为,定义接口

常见任务
内置JSON解析
利用反射实现,通过 FeildTag 来标识对应的 json 值(性能很低,不适用于高 QPS 场景)
Expand/Collapse Code Block
| |
easyjson
EasyJSON 采用代码生成而非反射
安装:
| |
使用:
| |
HTTP服务
路由规则:
1、URL 分为两种,末尾是 / 表示一个子树,后面可以跟其他子路径;末尾不是 /,表示一个叶子,固定的路径以 / 结尾的 URL 可以匹配它的任何子路径,比如 /images/ 会匹配 /images/cute-cat.jpg
2、它采用最长匹配原则,如果有多个匹配,一定采用匹配路径最长的那个进行处理。
3、如果没有找到任何匹配项,会返回 404 错误。
代码示例:
| |
Restful服务
更好的 Router
https://github.com/julienschmidt/httprouter
Expand/Collapse Code Block
| |
性能调优
性能分析工具
准备工作:
1、安装 graphviz
| |
2、将 $GOPATH 加入到 $PATH Mac OS:在 .bash_profile 中修改路径
3、安装 go-torch(go1.1之后已经内置)
1)go get
| |
2)下载并复制 flamegraph.pl (https://github.com/brendangregg/FlameGraph)至 $GOPATH/bin 路径下 3)将 $GOPATH/bin 加入 $PATH
通过文件方式输出 Profile
1、灵活性高,适用于特定代码段的分析
2、通过手动调用 runtime/pprof 的 API
3、API 相关文档 https://pkg.go.dev/runtime/pprof
4、
| |
go 支持多种 profile
1、go help testflag
2、https://pkg.go.dev/github.com/pkg/profile
代码示例:https://github.com/chnherb/go-demo/tree/master/ch17_tools/file
查看 profile
| |
通过 HTTP 方式输出 Profile
1、简单,适合于持续运行的应用
2、在应用程序中导入 import _ "net/http/pprof" ,并启动 http server 即可
3、http://
4、
| |
5、
| |
锁的性能
sync.Map
1、适合读多写少,且 key 相对稳定的环境。
2、采用了空间换时间的方案,并且采用指针的方式简介实现值的映射,所以存储空间会较 built-in map 大。
Concurrent Map
1、适用于读写都很频繁的情况
https://github.com/easierway/concurrent_map
代码示例:https://github.com/chnherb/go-demo/tree/master/ch18_lock
GC友好的代码
避免内存分配和复制
1、复杂对象尽量传递引用
1)数组的传递
2)结构体传递
2、初始化至合适的大小
1)自动扩容是有代价的
3、复用内存
打开 GC 日志
只要在程序执行之前加上环境变量 GODEBUG=gctrace=1,如:
| |
日志详细信息参考:https://pkg.go.dev/runtime
go tool trace
普通程序输出 trace 信息
| |
测试程序输出 trace 信息
| |
可视化 trace 信息
| |
6.4 - util-cli
本文主要介绍一个基于Golang语言的cli工具,以及部署、使用方法整个链路的流程
背景
原来使用石墨文档记录笔记,因为种种原因后来想换成github的pages记录,但是石墨文档导出的MD文件是将图片的原始数据base64加密直接存储在MD文件中,对于原始MD文件的查看是极不友好的,因此想写一个脚本工具将图片原始进行转换,还原MD源文件的简洁。
功能
- 解析 MD 文件中的 base64 图片数据
- 将 base64 图片数据存储到本地相对文件夹的对应文件中
- 文件名自动编号
- 自动递归编译指定文件夹中的所有MD文件
实现
- 使用正则匹配md文件中的base64图片数据
- 基于go语言的cobra包实现命令行交互
- 基于slog包控制界面信息的展示
源码
上传到GitHub:https://github.com/chnherb/util-cli
部署
| |
下载方法
| |
使用说明
| |
6.5 - mod包冲突解决实战01
背景
Go升级包之后编译报错
常用命令
Expand/Collapse Code Block
| |
分析过程
错误1
| |
解决1
分析:尝试升级 cel-go 依赖库
| |
然后 go mod tidy,提示
| |
便执行
| |
错误2
编译报错:
| |
解决2
通过报错信息可以发现 code.byted.org/iesarch/samsarahq_thunder 依赖 code.byted.org/whale/govaluate 包,但出现参数不匹配的问题。
对比之前master代码,发现 code.byted.org/iesarch/samsarahq_thunder 版本无变化,code.byted.org/whale/govaluate 版本由 v1.0.1 升级到了 v1.0.5(也可以查看该包所有版本)。顾降级该版本。
| |
重新执行:
| |
然后重新编译
错误3
| |
解决3
分析 code.byted.org/eventbus/client-go 原来master代码是 v1.10.1,现在的版本还是一样,查找该包所有版本:
| |
也可直接升级到最新版本:
| |
查看mod文件升级到 v1.13.5,然后tidy之后重新编译。
问题4
| |
解决4
参考:
https://github.com/mattn/go-sqlite3/issues/822 https://github.com/mattn/go-sqlite3/issues/803
查看
| |
解决:
| |
7 - Python
Introduction
Python相关知识
7.1 - pydemo
语法
路径
Expand/Collapse Code Block
| |
format
| |
去除英式符号
| |
groupby
| |
split_list
| |
date日期
| |
datetime时间
Expand/Collapse Code Block
| |
去除全角半角
| |
populate_obj
| |
使用excel
| |
打包
| |
装饰器
参考:https://zhuanlan.zhihu.com/p/45458873
高阶函数:接受函数为入参,或者把函数作为结果返回的函数。后者称之为嵌套函数。
闭包:指延伸了作用域的函数,其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。概念比较晦涩,简单来说就是嵌套函数引用了外层函数的变量。
高阶函数
| |
装饰器
| |
装饰器的加载到执行的流程:
模块加载 ->> 遇到@,执行timer函数,传入add函数 ->> 生成timer.
带参数的装饰器
Expand/Collapse Code Block
| |
functools.wraps对我们的装饰器函数进行了装饰之后,add表面上看起来还是add。 functools.wraps内部通过partial和update_wrapper对函数进行再加工,将原始被装饰函数(add)的属性拷贝给装饰器函数(wrapper)。
functools
参考:https://zhuanlan.zhihu.com/p/45535784
wraps
同带参修饰器
partial
| |
add函数原本接收两个参数a和b,经过partial包装之后,a参数的值被固定为了1,新的add对象(注意此处add已经是一个可调用对象,而非函数,下文分析源码会看到)只需要接收一个参数即可。 通俗点说:就是把原函数的部分参数固定了初始值,新的调用只需要传递其它参数。
7.2 - selenium使用
使用selenium操作已经打开的浏览器页面,避免输入密码等繁杂操作
背景
在使用selenium进行自动化测试会遇到,手工打开浏览器,做了一部分操作后,并打开相关页面后再执行相关的自动化脚本。如何使用selenium来接管先前已打开的浏览器呢?醍提出一个Google Chrome浏览器的解决方案。
实战
我们可以利用Chrome DevTools协议。它允许客户检查和调试Chrome浏览器。
将执行文件加入path:
| |
输入以下内容
| |
保存并退出
| |
打开cmd,在命令行中输入命令:
| |
mac中:
| |
对于-remote-debugging-port值,可以指定任何打开的端口。
对于-user-data-dir标记,指定创建新Chrome配置文件的目录。它是为了确保在单独的配置文件中启动chrome,不会污染你的默认配置文件。
此时会打开一个浏览器页面,我们输入百度网址,我们把它当成一个已存在的浏览器:
现在,我们需要接管上面的浏览器。新建一个python文件,运行以下代码:
| |
8 - Cpp
Introduction
C和Cpp相关知识
8.1 - cpp入门-01基础语法
前言
为什么要学 cpp 这门语言?
自从大一学了 c 和 cpp 这两门语言后,除了后来学习数据结构、操作系统、Linux 用QT做了个课程设计,后来再也没怎么用过这两门语言了。后来研究生毕业找工作选择了 Java,这几年都是在看 Java 各种中间件源码,可以说 Java 水平应该还是可以的,只要稍花时间能够解决各类问题。
在基本学完 RPC/MQ/ES 等中间件的入门知识后,延着分布式的路径逐步去学习存储时,基本上都是基于 cpp 来实现的,比如 kv、newsql、文档数据库等等。底层的高性能组件基本都是 cpp 实现的,因此,捡起来原来丢的知识是值当的。其实是因为我想去看下像 leveldb/rocksdb 这种经典的源码。
基础语法
头文件-命名空间
1、C++头文件不必是 .h 结尾,C语言中的标准库头文件如 math.h、stdio.h 在 C++ 被命名为 cmath、cstdio(去掉 .h 增加了 c 头)。
比如:cstring是c语言的,string是c++的。
2、除了 C 的多行注释,C++ 可以使用单行注释
| |
3、命名空间namespace
为了防止名字冲突,C++引入了命名空间(namespace,也称名字空间)。
通过 :: 运算符限定某个名字属于哪个命名空间。
通常有3种方法使用名字空间X的名字name:
| |
标准输入输出流
C++ 新的输入输出流库(头文件iostream)将输入输出看成一个流,并用输出运算符 << 和输入运算符 >> 对数据(变量和常量进行输入输出)。其中有 cout 和 cin 分别代表标准输出流对象(屏幕窗口)和标准输入流对象(键盘),标准库中的名字都属于标准命名空间 std。
| |
变量
变量定义
变量“即用即定义”,且可用表达式初始化
| |
变量作用域
程序块 {} 内部作用域可定义域外部作用域同名的变量,在该块里隐藏了外部变量。
| |
局部变量
for 循环语句可以定义局部变量。
Expand/Collapse Code Block
| |
全局变量
访问和内部作用域变量同名的全局变量,要用全局作用域限定 ::
| |
引用
C++引入了“引用类型”,即一个变量是另一个变量的别名。
| |
swapExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
using namespace std;
void swap(int x, int y) {
cout << "swap before: " << x << " " << y << endl;
int t = x;
x = y;
y = t;
cout << "swap after: " << x << " " << y << endl << endl;
}
// 修改的是 x,y 指向的那 2 个 int 型变量的内容
void swap2(int *x, int *y) {
cout << "swap before: " << *x << " " << *y << endl;
int t = *x;
*x = *y;
*y = t;
cout << "swap after: " << *x << " " << *y << endl << endl;
}
// x,y 是实参的引用
void swap3(int &x, int &y) {
cout << "swap before: " << x << " " << y << endl;
int t = x;
x = y;
y = t;
cout << "swap after: " << x << " " << y << endl << endl;
}
int main()
{
int a = 2, b = 3;
swap(a, b);
cout << "main : " << a << " " << b << endl << endl; // 2 3
a = 2, b = 3;
swap2(&a, &b);
cout << "main : " << a << " " << b << endl << endl; // 3 2
a = 2, b = 3;
swap3(a, b);
cout << "main : " << a << " " << b << endl << endl; // 3 2
return 0;
}
当实参栈内存大时,用引用代替传值(需要复制)可提高效率。 如果不希望因此无意中改变实参,可以用 const 修饰符,如:
| |
函数
内联函数
对于不包含循环的简单函数,建议用inline关键字声明为“inline内联函数”,编译器将内联函数调用其代码展开,称为“内联展开”,避免函数调用开销,提高程序执行效率。
| |
默认形参
默认形参:函数的形参可带有默认值。必须一律在最右边
| |
函数重载
C++ 允许函数同名,只要它们的形参不一样(个数或对应参数类型),调用函数时将根据实参和形参的匹配选择最佳函数,如果有多个难以区分的最佳函数,则变化一起报错!
注意:不能根据返回类型区分同名函数。
| |
运算符重载
Expand/Collapse Code Block
| |
模板函数
厌倦了重复功能的实现。
| |
不同类型比较
| |
异常
try-catch
通过 try-catch 处理异常情况。正常代码放在 try 块,catch 中捕获 try 块抛出的异常。
| |
动态内存分配
关键字 new 和 delete 比C语言的 malloc/alloc/realloc 和 free 更好,可以对类对象调用初始化构造函数或销毁析构函数。
Expand/Collapse Code Block
| |
类
类的定义
类:是在 C 的 struct 类型上,增加了“成员函数”。C 的 struct 可将一个概念或实体的所有属性组合在一起,描述同一类对象的共同属性。
C++ 使得 struct 不但包含数据,还包含函数(方法)用于访问或修改类变量(对象)的属性。
Expand/Collapse Code Block
| |
自引用
Expand/Collapse Code Block
| |
成员函数重载运算符
Expand/Collapse Code Block
| |
构造函数
Expand/Collapse Code Block
| |
析构函数
Expand/Collapse Code Block
| |
访问控制与接口
class 定义的类成员默认是 private 的, struct 定义的类成员默认是 public 的。
接口:public的公开成员(一般是成员函数)称为这个类的对外接口,外部函数只能通过这些接口访问类对象。private等非public的包含内部细节,不对外公开,从而可以封装保护类对象!
Expand/Collapse Code Block
| |
拷贝构造函数
拷贝构造函数:定义一个类对象时用同类型的另外对象初始化。
赋值运算符:一个对象赋值给另外一个对象。
默认的拷贝函数析构函数可能会有问题,如硬拷贝,举例如下。
硬拷贝1
Expand/Collapse Code Block
| |
硬拷贝2
| |
拷贝构造函数示例
Expand/Collapse Code Block
| |
类体外定义成员函数
Expand/Collapse Code Block
| |
类模板
Expand/Collapse Code Block
| |
类别名typedef
| |
string
string赋值
Expand/Collapse Code Block
| |
string遍历
Expand/Collapse Code Block
| |
vector
Expand/Collapse Code Block
| |
继承与多态
派生类
Inheritance 继承(Derivation派生):一个派生类(derived class)从1个或多个父类(parent class)/基类(base class)继承,即继承父类的属性和行为。
Expand/Collapse Code Block
| |
虚函数与多态
将上面派生类的示例,基类的函数修改为虚函数,就能实现多态。
| |
多重继承
多重派生(Multiple inheritance):从多个不同的类派生出一个类来。
| |
纯虚函数和抽象类
函数体=0的虚函数称为“纯虚函数”。包含纯虚函数的类称为“抽象类”。抽象类不能被实例化。抽象类通常用来定义接口。
Expand/Collapse Code Block
| |
实现纯虚函数:
Expand/Collapse Code Block
| |
Reference
9 - 数据库
Introduction
数据库相关,如MySQL、Redis或其他类型db
9.1 - 分布式数据库理论概述
简介
对比单体数据库
这里主要针对关系型数据,像 MongoDB 这样的 NoSQL 产品不是这里的重点。
传统的单体数据需要做好 查询、事务、存储、复制和其它 等五个方面,分布式数据库在单体数据库的基础上还需要增加对 分片 的处理。并且难点重点在 查询、事务、复制和分片 这四个方面。
单体数据库:
- 查询
- 事务
- 存储
- 复制
- 其它
分布式数据库:
- 查询
- 计算下推
- 多表关联
- 事务
- 隔离性
- 原子性
- 分片
- 分片元数据存储
- 合并拆分
- 调度
- 存储
- 写入效率
- 读取效率
- 存储成本
- 复制
- 主从复制
- Quorum
- 其它
- 客户端接入
- 权限管理
- 元数据存储
定义
外部视角
业务应用系统可以按照交易类型分为联机交易(OLTP)场景和联机分析(OLAP)场景两大类。OLTP 是面向交易的处理过程,单笔交易的数据量小,但要在短时间内响应,典型场景包括电商、转账等;而 OLAP 场景通常是基于大数据集的运算,典型场景包括生成年度账单和财务报表等。
从外部视角可以有如下定义:
OLTP 关系型数据库 写多读少;低延迟;高并发
海量并发
高可靠
海量存储 所以从外部视角最终定义可以是:分布式数据库是服务于写多读少、低延时、海量并发 OLTP 场景的,具备海量数据存储能力和高可靠性的关系型数据库。
内部视角
客户端组件 + 单体数据库 典型的客户端组件就是 Sharding-JDBC。
代理中间件 + 单体数据库 典型的就是中间件产品就是 MyCat。
单元化架构 + 单体数据库 单元化架构对业务应用系统需要彻底重构,应用系统被拆分成若干实例,配置独立的单体数据库,让每个实例管理一定范围的数据。当出现跨实例事务时通过分布式事务组件保证,不同的分布式事务模型,应用系统都需要配合改造。该方案改造量最大,实施难度最高。
总结:
传统的单体数据库仍然能够被应用系统感知到。而分布式数据库对外单体数据库是透明的,将技术细节收敛到产品内部,以一个整体面对业务应用。
一致性
分布式数据库的一致性,一般是指数据一致性和事务一致性两个方面。
数据一致性
数据一致性可分为:
状态一致性(State Consistency) 数据所处的客观、实际状态所体现的一致性。
操作一致性(Operation Consistency) 外部用户通过协议约定的操作,能够读取到的数据一致性。
状态一致性
强一致性
如 MySQL 全同步复制。
问题:
- 性能差:主库必须等多个从库均返回成功后,才向客户端返回成功。主库的响应时间取决于多个从库中延时最长的那个。
- 可用性问题:全同步复制模式下,多个节点被串联,如果单机可用性 99%,那么集群可用性为 99%*99%*99%,比单机更低。
弱一致性
如 NoSQL 最终一致性。也就是 BASE 理论中的 E 代表的最终一致性(Eventually Consistency)。
最终一致性可以理解为:主副本执行写操作成功后直接响应客户端,不要求其他副本与主副本实时保持一致,经过一段时间,其它副本会逐渐追赶上主副本。
操作一致性
读写一致性
写后读一致性(Read after Write Consistency),它也称为 读写一致性 或 读自己写一致性(Read My Writes Consistency)。表示自己写入的数据,下一刻一定能够读取。
单调读一致性
之前能读到,刷新了之后依然能够读到。避免出现上一刻读副本A,下一刻读副本B,导致前后数据不一致。避免这种问题就需要实现 单调读一致性。
前缀一致性
保持因果关系的一致性,被称为前缀读或前缀一致性(Consistent Prefix)。举例出现时间上的扭曲:评论A在评论B之前,避免出现时间上的乱序。
线性一致性
分布式数据库无法要求应用系统每次变更操作都附带显式声明,如变更是因为读取哪些数据导致。更可靠的方式是将自然语义的因果关系转变为事件发生的先后顺序。
线性一致性(Linearizability)就是建立在事件的先后顺序之上。整个系统的所有操作被记录在一条时间线下且被原子化,表现得就像只有一个副本。
因为各个节点都是各自的时间线,所以做到全局线性一致性需要一个全局时钟。主流数据大多以实现线性一致性为目标,在设计之初就引入了全局时钟(如 Spanner、TiDB、OceanBase、GoldenDB 和巨杉),多数采用单点授时(TSO)。
对于线性一致性,当然也有一些争议,反对者认为没有绝对时间,时间都是相对的,自然不存在全序的事件顺序,不同的观察者对于事件的发生顺序无法达成一致(相对论)。
因果一致性
线性一致性存在争议,那可以不依赖绝对时间。
因果一致性的基础是偏序关系,即部分事件顺序可以比较。如一个节点内的事件可以排序,仅依靠节点的本地时钟,如果节点发生通讯,则按照接收方事件晚于调用方事件来处理。
基于这种偏序关系,Leslie Lamport 在论文“Time, Clocks, and the Ordering of Events in a Distributed System”中提出了逻辑时钟的概念。
借助逻辑时钟也可以建立全序关系,只不过这个全序关系不够精确,如两个事件没有相关性,逻辑时钟给出的大小就没有意义。
因果一致性弱于线性一致性,但在并发性能上具有优势,也足以处理多数的异常现象,所以因果一致性也在工业界得到了应用。CockroachDB 和 YugabyteDB 都在设计中采用了逻辑混合时钟(Hybrid Logical Clocks)。
事务一致性
(事务一致性在 MySQL 章节已详细介绍,这里简单介绍)
事务的 ACID 四大特性,将广义上的事务一致性具化到了:
- 原子性:事务中的所有变更要么全部发生,要么一个也不发生。
- 一致性:事务要保持数据的完整性。
- 隔离性:多事务并行执行所得到的结果,与串行执行(一个接一个)完全相同。
- 持久性:一旦事务提交,它对数据的改变将被永久保留,不应受到任何系统故障的影响。
隔离性
隔离级别:
- 未提交读(RU)
- 以提交读(RC)
- 可重复读(RR)
- 可串行化(Serializable) 隔离性是事务的核心。降低隔离级别就是在正确性上做妥协,将一些异常现象交给业务去处理,从而获得更好的性能。除 串行化 以外的隔离级别,都有无法处理的异常现象。
原子性
事务原子性
原子性要求事务只有两种状态:
成功,所有操作全部成功;
失败,任何操作都没有被执行,即使过程中执行了部分操作,也要保证回滚这些操作。 原子性提交协议有不少,按照其作用范围可以分为面向应用层和面向资源层。下面介绍两种协议:
面向应用层的 TCC
数据库领域常用的 2PC
TCC
TCC 是 Try、Confirm 和 Cancel 三个单词的缩写,是事务过程中的三个操作。
2PC
两阶段提交协议(Two-Phase Commit,2PC),这也是面向资源层的典型协议。
2PC 的首次正式提出是在 Jim Gray 1977 年发表的一份文稿中,文稿的题目是“Notes on Data Base Operating Systems”,对当时数据库系统研究成果和实践进行了总结,而 2PC 在工程中的应用还要再早上几年。
2PC 的处理过程也分为准备和提交两个阶段,每个阶段都由事务管理器与资源管理器共同完成。其中,事务管理器作为事务的协调者只有一个,而资源管理器作为参与者执行具体操作允许有多个。
问题
相比于 TCC,2PC 的优点是借助了数据库的提交和回滚操作,不侵入业务逻辑。但是,它也存在一些明显的问题:
同步阻塞 执行过程中,数据库要锁定对应的数据行。如果其他事务刚好也要操作这些数据行,那就只能等待。其实同步阻塞只是设计方式,真正的问题在于这种设计会导致分布式事务出现高延迟和性能的显著下降。
单点故障 事务管理器非常重要,一旦发生故障,数据库会一直阻塞下去。尤其是在第二阶段发生故障的话,所有数据库还都处于锁定事务资源的状态中,从而无法继续完成事务操作。
数据不一致 在第二阶段,当事务管理器向参与者发送 Commit 请求之后,发生了局部网络异常,导致只有部分数据库接收到请求,但是其他数据库未接到请求所以无法提交事务,整个系统就会出现数据不一致性的现象。比如转账余额已经能够扣减,但另一方余额没有增加,就不符合原子性的要求。
两个2PC改进模型
NewSQL阵营:Percolator
Percolator 来自 Google 的论文“Large-scale Incremental Processing Using Distributed Transactions and Notifications”,因为它是基于分布式存储系统 BigTable 建立的模型,所以可以和 NewSQL 无缝链接。
Percolator 模型同时涉及了隔离性和原子性的处理,本节仅介绍原子性的部分。
改进:
数据不一致 2PC 的一致性问题主要缘自第二阶段,不能确保事务管理器与多个参与者的通讯始终正常。但在 Percolator 的第二阶段,事务管理器只需要与一个分片通讯,这个 Commit 操作本身就是原子的。所以,事务的状态自然也是原子的,一致性问题被完美解决了。
单点故障 Percolator 通过日志和异步线程的方式弱化了该问题。
- Percolator 引入的异步线程可以在事务管理器宕机后,回滚各个分片上的事务,提供了善后手段,不会让分片上被占用的资源无法释放。
- 事务管理器可以用记录日志的方式使自身无状态化,日志通过共识算法同时保存在系统的多个节点上。事务管理器宕机后,可以在其他节点启动新的事务管理器,基于日志恢复事务操作。
Proxy阵营:GoldenDB一阶段提交
GoldenDB 展现了另外一种改良思路,称之为“一阶段提交”。GoldenDB 遵循 Proxy 架构,包含了四种角色:协调节点、数据节点、全局事务器和管理节点,其中协调节点和数据节点均有多个。GoldenDB 的数据节点由 MySQL 担任,后者是独立的单体数据库。
虽然叫做“一阶段提交”,但流程仍可以分为两个阶段:
第一阶段,GoldenDB 的协调节点接到事务后,在全局事务管理器(GTM)的全局事务列表中将事务标记成活跃的状态。这个标记过程是 GoldenDB 的主要改进点,实质是通过全局事务列表来申请资源,规避可能存在的事务竞争。
好处是避免了与所有参与者的通讯,也减少了很多无效的资源锁定动作。
第二阶段,协调节点把一个全局事务分拆成若干子事务,分配给对应的 MySQL 去执行。如果所有操作成功,协调者节点会将全局事务列表中的事务标记为结束,整个事务处理完成。如果失败,子事务在单机上自动回滚,而后反馈给协调者节点,后者向所有数据节点下发回滚指令。
本质上是改变了资源的申请方式,更准确的说法是,并发控制手段从锁调度变为时间戳排序(Timestamp Ordering)。在正常情况下协调节点与数据节点只通讯一次,降低了网络不确定性的影响,数据库的整体性能有明显提升。因为第一阶段不涉及数据节点的操作,也就弱化了数据一致性和单点故障的问题。
事务延迟
优化方法如下
缓存写提交
第一个办法是将所有写操作缓存起来,直到 commit 语句时一起执行,这种方式称为 Buffering Writes until Commit,这里称为“缓存写提交”。
管道
Pipe 既能缩短延迟,又能保持交互事务。CockroachDB 就是采用这种方式,具体过程就是在准备阶段是按照顺序将 SQL 转换为 K/V 操作并执行,但是并不等待返回结果,直接执行下一个 K/V 操作。
并行提交
(Parallel Commits)
在执行意向写的同时,写入事务标志,这时不能确定事务是否提交成功,要引入一个新的状态“Staging”,表示事务正在进行。
客户端得到所有意向写的成功反馈后,可以直接返回调用方事务提交成功。注意:这个地方是关键,客户端只在当前进程内判断事务提交成功后,不维护事务状态,而直接返回调用方;事后由异步线程根据事务表中的线索,再次确认事务的状态,并落盘维护状态记录。这样事务操作中就减少了一轮共识算法开销。
隔离性
多版本并发控制(Multi-Version Concurrency Control,MVCC)就是通过记录数据项历史版本的方式,来提升系统应对多事务访问的并发处理能力。
单体数据库的MVCC
MVCC存储方式
MVCC 有三类存储方式,一类是将历史版本直接存在数据表中的,称为 Append-Only,典型代表是 PostgreSQL。另外两类都是在独立的表空间存储历史版本,它们区别在于存储的方式是全量还是增量。增量存储就是只存储与版本间变更的部分,这种方式称为 Delta,也就是数学中常作为增量符号的那个 Delta,典型代表是 MySQL 和 Oracle。全量存储则是将每个版本的数据全部存储下来,这种方式称为 Time-Travle,典型代表是 HANA。
Append-Only方式
优点:
在事务包含大量更新操作时也能保持较高效率。因为更新操作被转换为 Delete + Insert,数据并未被迁移,只是有当前版本被标记为历史版本,磁盘操作的开销较小。
可以追溯更多的历史版本,不必担心回滚段被用完。
因为执行更新操作时,历史版本仍然留在数据表中,所以如果出现问题,事务能够快速完成回滚操作。 缺点:
新老数据放在一起,会增加磁盘寻址的开销,随着历史版本增多,会导致查询速度变慢。
Delta方式
优点:
因为历史版本独立存储,所以不会影响当前读的执行效率。
因为存储的只是变化的增量部分,所以占用存储空间较小。 缺点:
历史版本存储在回滚段中,而回滚段由所有事务共享,并且还是循环使用的。如果一个事务执行持续的时间较长,历史版本可能会被其他数据覆盖,无法查询。
这个模式下读取的历史版本,实际上是基于当前版本和多个增量版本计算追溯回来的,那么计算开销自然就比较大。 Oracle 早期版本中经常会出现的 ORA-01555 “快照过旧”(Snapshot Too Old),就是回滚段中的历史版本被覆盖造成的。一般设置更大的回滚段和缩短事务执行时间可以解决这个问题。随着 Oracle 后续版本采用自动管理回滚段的设计该问题也得到缓解。
Time-Travel方式
优点:
将历史版本独立存储,不会影响当前读的执行效率。
相对 Delta 方式,历史版本是全量独立存储的,直接访问即可,计算开销小。 缺点:
相对 Delta 方式,需要占用更大的存储空间。
MVCC工作过程
最低可接受的隔离级别就是“已提交读”(Read Committed,RC)。RC隔离级别下 MVCC 的工作过程:
- 当前事务的更新所产生的数据。
- 当前事务启动前,已经提交事务更新的数据。
快照工作原理
快照是基于 MVCC 实现的一个重要功能,用“快照”来实现 RR 是很方便的。
RC 与 RR 的区别在于 RC 下每个 SQL 语句会有一个自己的快照,所以看到的数据库是不同的,而 RR 下,所有 SQL 语句使用同一个快照,所以会看到同样的数据库。
为了提升效率,快照不是单纯的事务 ID 列表,它会统计最小活动事务 ID 和最大已提交事务 ID。因此多数事务 ID 通过比较边界值就能被快速排除掉,如果事务 ID 恰好在边界范围内,再进一步查找是否与活跃事务 ID 匹配。
快照在 MySQL 中称为 ReadView,在 PostgreSQL 中称为 SnapshotData,组织方式都是类似的。
Proxy读写冲突处理
Proxy 架构实现 RR 时遇到的两个挑战,也就是实现快照的两个挑战:
- 如何保证产生单调递增事务 ID。每个数据节点自行处理显然不行,这就需要由一个集中点来统一生成。
- 如何提供全局快照。每个事务要把自己的状态发送给一个集中点,由它维护一个全局事务列表,并向所有事务提供快照。 Proxy 架构的分布式数据库都有一个集中点,通常称为全局事务管理器(GTM)。又因为事务 ID 是单调递增的,用来衡量事务发生的先后顺序,和时间戳作用相近,所以全局事务管理器也被称为“全局时钟”。
NewSQL读写冲突处理
没有普遍采用快照解决读写冲突问题,其中 TiDB 是由于权衡全局事务列表的代价,CockroachDB 则是因为要实现更高的隔离级别。无论哪种原因都造成了读写并行能力的下降。
隐式读写冲突
不确定时间窗口
当两个时间窗口时重叠时,无法判断时间先后关系。只有通过避免时间窗口出现重叠来解决,而避免重叠只能是等待(waiting out the uncertainty”,用等待来消除不确定性)。
写等待:Spanner
Spanner 选择了写等待方式,更准确地说是用提交等待(commit-wait)来消除不确定性。
Spanner 是直接将时间误差暴露出来的,所以调用当前时间函数 TT.now() 时,会获得的是一个区间对象 TTinterval。它的两个边界值 earliest 和 latest 分别代表了最早可能时间和最晚可能时间,而绝对时间就在这两者之间。另外,Spanner 还提供了 TT.before() 和 TT.after() 作为辅助函数,其中 TT.after() 用于判断当前时间是否晚于指定时间。
理论等待时间

写等待的处理方式是:
事务 Ta 在获得“提交时间戳”S 后,再等待ɛ时间后才写盘并提交事务。真正的提交时间是晚于“提交时间戳”的,中间这段时间就是等待。这样 Tb 事务启动后,能够得到的最早时间 TT2.earliet 肯定不会早于 S 时刻,所以 Tb 就一定能够读取到 Ta。这样就符合线性一致性的要求了。
事务获得“提交时间戳”后必须等待ɛ时间才能写入磁盘,即 commit-wait。
实际等待时间

针对同一个数据项,事务 T8 和 T9 分别对进行写入和读取操作。T8 在绝对时间 100ms 的时候,调用 TT.now() 函数,得到一个时间区间[99,103],选择最大值 103 作为提交时间戳,而后等待 8 毫秒(即 2ɛ)后提交。
无论如何 T9 事务启动时间都晚于 T8 的“提交时间戳”,也就能读取到 T8 的更新。
回顾一下这个过程,第一个时间差是 2PC 带来的,如果换成其他事务模型也许可以避免,而第二个时间差是真正的 commit-wait,来自时间的不确定性,是不能避免的。
TrueTime 的平均误差是 4 毫秒,commit-wait 需要等待两个周期,那 Spanner 读写事务的平均延迟必然大于等于 8 毫秒。为啥有人会说 Spanner 的 TPS 是 125 呢?原因就是这个。其实,这只是事务操作数据出现重叠时的吞吐量,而无关的读写事务是可以并行处理的。
对数据库来说 8 毫秒的延迟虽然不能说短,但对多数场景来说还是能接受的。可是,TrueTime 是 Google 的独门招式,其他分布式数据库的时间误差远大于 8 毫秒,难道也用 commit-wait 可不太行,所以要用到第二种方式:读等待。
读等待:CockroachDB
读等待的代表产品是 CockroachDB。
CockroachDB 采用混合逻辑时钟(HLC),对于没有直接关联的事务,只能用物理时钟比较先后关系。CockroachDB 各节点的物理时钟使用 NTP 机制同步,误差在几十至几百毫秒之间,用户可以基于网络情况通过参数”maximum clock offset”设置这个误差,默认配置是 250 毫秒。
写等待模式下,所有包含写操作的事务都受到影响,要延后提交;而读等待只在特殊条件下才被触发,影响的范围要小得多。

这时,CockroachDB 的办法是重启(Restart)读操作的事务,就是让 T6 获得一个更晚的时间戳 T6-S2,使得 T6-S2 与 T2-C 的间隔大于 offset,那么就能读取 T2 的写入了。

不过,接下来又出现更复杂的情况, T6-S2 与 T3 的提交时间戳 T3-C 间隔太近,又落入了 T3 的不确定时间窗口,所以 T6 事务还需要再次重启。而 T3 之后,T6 还要重启越过 T4 的不确定时间窗口。

最后,当 T6 拿到时间戳 T6-S4 后,终于跳过了所有不确定时间窗口,读等待过程到此结束,T6 可以正式开始它的工作了。
在这个过程中,可以看到读等待的两个特点:一是偶发,只有当读操作与已提交事务间隔小于设置的时间误差时才会发生;二是等待时间的更长,因为事务在重启后可能落入下一个不确定时间窗口,所以也许需要经过多次重启。
并发控制技术
并发控制技术的分类:
- 乐观协议
- 悲观协议 悲观协议是使用锁的,而乐观协议是不使用锁的。
乐观锁:TiDB
TiDB 的乐观锁基本上就是 Percolator 模型,运行过程分为三个阶段:
选择Primary Row 收集所有参与修改的行,从中随机选择一行,作为这个事务的 Primary Row,这一行是拥有锁的,称为 Primary Lock,而且这个锁会负责标记整个事务的完成状态。所有其他修改行也有锁,称为 Secondary Lock,都会保留指向 Primary Row 的指针。
写入阶段 按照两阶段提交的顺序,执行第一阶段。每个修改行都会执行上锁并执行“prewrite”,prewrite 就是将数据写入私有版本,其他事务不可见。注意这时候每个修改行都可能碰到锁冲突的情况,如果冲突就终止事务,返回给 TiDB,整个事务也就终止。如果所有修改行都顺利上锁,完成 prewrite,第一阶段结束。
提交阶段 这是两阶段提交的第二阶段,提交 Primary Row,也就是写入新版本的提交记录并清除 Primary Lock,如果顺利完成,那么这个事务整体也就完成了,反之就是失败。而 Secondary Rows 上的锁,则会交给异步线程根据 Primary Lock 的状态去清理。
并发控制阶段
- 读阶段 每个事务对数据项的局部拷贝进行更新。
注意此时的更新结果对于其他事务不可见。这个阶段的命名容易让人误解,明明做了写操作,却叫做“读阶段”。大意为后面要写入的内容,先要暂时加载到一个仅自己可见的临时空间内。
- 有效性确认阶段 验证准备提交的事务。检查这些更新是否可以保证数据库的一致性,如果检查通过进入下一个阶段,否则取消事务。
首先这里提到的检查与隔离性目标有直接联系;其次就是检查可以有不同的手段,也就是不同的并发控制技术,比如可以是基于锁的检查,也可以是基于时间戳排序。
- 写阶段 将读阶段的更新结果写入到数据库中,接受事务的提交结果。
还有一种关于乐观与悲观的表述,也与三阶段的顺序相呼应。乐观重在事后检测,在事务提交时检查是否满足隔离级别,如果满足则提交,否则回滚并自动重新执行。悲观重在事前预防,在事务执行时检查是否满足隔离级别,如果满足则继续执行,否则等待或回滚。
回到 TiDB 的乐观锁。虽然对于每一个修改行来说,TiDB 都做了有效性验证,而且顺序是 VRW,可以说是悲观的,但这只是局部的有效性验证;从整体看,TiDB 没有做全局有效性验证,不符合 VRW 顺序,所以还是相对乐观的。
狭义乐观并发控制(OCC)
“Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery”给出了一个专用于 RVW 的三阶段定义,专门描述乐观协议的。其中主要差别在“有效性确认阶段”,是针对可串行化的检查,检查采用基于时间戳的特定算法。
这个定义是一个更加具体的乐观协议,严格符合 RVW 顺序,所以我把它称为狭义上的乐观并发控制(Optimistic Concurrency Control),也称为基于有效性确认的并发控制(Validation-Based Concurrency Control)。很多学术论文中的 OCC 就是指这个。在工业界真正生产级的分布式数据库还很少使用狭义 OCC 进行并发控制,唯一的例外就是 FoundationDB。与之相对应的,则是 TiDB 这种广义上的乐观并发控制,说它乐观是因为它没有严格遵循 VRW 顺序。
乐观协议的挑战
主要两方面:
- 事务冲突少是使用乐观协议的前提,但这个前提是否普遍成立?
- 现有应用系统使用的单体数据库多是悲观协议,兼容性上的挑战。
事务频繁冲突
金融业务频繁冲突,很可能一直在重试、回滚,永远无法执行完成,而使用悲观锁就很容易解决。
兼容性要求
保证对遗留应用系统的兼容性。单体数据库都是悲观协议,甚至多数都是基于锁的悲观协议,所以在 SQL 运行效果上与乐观协议有直接的区别。一个非常典型的例子就是 select for update。这是一个显式的加锁操作,或者说是显式的方式进行有效性确认,广义的乐观协议都不提供严格的 RVW,所以也就无法支持这个操作。
乐观锁的改变
基于上面这些挑战,TiDB 的并发控制机制也做出了改变,增加了“悲观锁”并作为默认选项。TiDB 悲观锁的理论基础很简单,就是在原有的局部有效性确认前,增加一轮全局有效性确认。这样就是严格的 VRW,自然就是标准的悲观协议了。具体采用的方式就是增加了悲观锁,这个锁是实际存在的,表现为一个占位符,随着 SQL 的执行即时向存储系统(TiKV)发出,这样事务就可以在第一时间发现是否有其他事务与自己冲突。
悲观锁还触发了一个变化。TiDB 原有的事务模型并不是一个交互事务,它会把所有的写 SQL 都攒在一起,在 commit 阶段一起提交,所以有很大的并行度,锁的时间较短,死锁的概率也就较低。因为增加了悲观锁的加锁动作,变回了一个可交互事务,TiDB 还要增加一个死锁检测机制。
悲观锁
分类
悲观协议又分为基于锁和非锁两大类,其中基于锁的协议是数量最多的。
两阶段封锁
Two-Phase Locking,2PL。就是事务具备两阶段特点的并发控制协议,两阶段指加锁阶段和释放锁阶段,并且加锁阶段严格区别于紧接着的释放锁阶段。
保守两阶段封锁协议(Conservative 2PL,C2PL),事务在开始时设置它需要的所有锁。
严格两阶段封锁协议(Strict 2PL,S2PL),事务一直持有已经获得的所有写锁,直到事务终止。
强两阶段封锁协议(Strong Strict 2PL,SS2PL),事务一直持有已经获得的所有锁,包括写锁和读锁,直到事务终止。SS2PL 与 S2PL 差别只在于一直持有的锁的类型,所以它们的图形是相同的。
串行化图检测(SGT)
SSI 是一种隔离级别的命名,最早来自 PostgreSQL,CockroachDB 沿用了这个名称。它是在 SI 基础上实现的可串行化隔离。作为 SSI 核心的 SGT 也不是 CockroachDB 首创,学术界早就提出了这个理论,真正的工程化实现要晚得多。
理论来源:PostgreSQL
事务之间的边又分为三类情况:
- 写读依赖(WR-Dependencies),第二个操作读取了第一个操作写入的值。
- 写写依赖(WW-Dependencies),第二个操作覆盖了第一个操作写入的值。
- 读写反依赖(RW-Antidependencies),第二个操作覆盖了第一个操作读取的值,可能导致读取值过期。
工程实现:CockroachDB
RW 反向依赖是一个非常特别的存在,而特别之处就在于传统的锁机制无法记录这种情况。因此在论文“Serializable Snapshot Isolation in PostgreSQL”中提出,增加一种锁 SIREAD,用来记录快照隔离(SI)上所有执行过的读操作(Read),从而识别 RW 反向依赖。本质上,SIREAD 并不是锁,只是一种标识。但这个方案面临的困境是,读操作涉及到的数据范围实在太大,跟踪标识带来的成本可能比 S2PL 还要高,也就无法达到最初的目标。
针对这个问题,CockroachDB 做了一个关键设计,读时间戳缓存(Read Timestamp Cache),简称 RTC。
基于 RTC 的新方案是这样的,当执行任何的读取操作时,操作的时间戳都会被记录在所访问节点的本地 RTC 中。当任何写操作访问这个节点时,都会以将要访问的 Key 为输入,向 RTC 查询最大的读时间戳(MRT),如果 MRT 大于这个写入操作的时间戳,那继续写入就会形成 RW 依赖。这时就必须终止并重启写入事务,让写入事务拿到一个更大的时间戳重新尝试。
具体来说,RTC 是以 Key 的范围来组织读时间戳的。这样,当读取操作携带了谓词条件,比如 where 子句,对应的操作就是一个范围读取,会覆盖若干个 Key,那么整个 Key 的范围也可以被记录在 RTC 中。这样处理的好处是,可以兼容一种特殊情况。
架构演进
单体数据往分布式数据库演进主要有两种方式,一种是通过增加中间件如 MyCat 来分库分表,实际就是在多个单体数据库之前增加代理节点,这里称作 Proxy 吧。另一种是提供一个完整的分布式数据库。
Proxy
在多个单体数据库之前增加代理节点,本质上是增加 SQL 的路由功能。随着分布式事务和跨节点等功能的加入,代理节点不再仅仅承担路由功能,还会承担分布式事务管理,可以成为协调节点。主要实现以下功能:
- 客户端接入
- 简单的查询处理器
- 进程管理中的访问控制
- 分布式事务管理

NewSQL
NewSQL 也叫原生分布式数据,在架构上更加先进,每个层次的设计都是以分布式为目标,是从分布式键值对系统演进而来。在 NoSQL 的基础上增加了数据库事务处理能力。主要的工作负载由计算节点和存储节点承担,另外由管理节点承担全局时钟和分片信息管理功能。存储引擎层使用 LSM-Tree 模型替换 B+ Tree 模型,大幅提升了写入性能。

Spanner 是 NewSQL 的开山鼻祖,其它的知名度较高的还有 CockroachDB、TiDB 和 YugabyteDB。
全局时钟
分布式数据库的很多设计都和时间有关,更确切地说是和全局时钟有关。比如前面提到的线性一致性,其基础就是全局时钟,还有多版本并发控制(MVCC)、快照、乐观协议与悲观协议,都和时间有关。
常见授时方案
授时机制三要素:
- 时间源:单个还是多个
- 时钟类型:物理时钟还是混合逻辑时钟
- 授时点:一个还是多个 排列组合一共 8 种可能性,其中 NTP(Network Time Protocol)误差大,也不能保证递增,基本没有使用其的产品。还有一些方案在实际中不适用(N/A),因此常见方案只有 4 类。

TrueTime
Spanner 采用的方案是 TrueTime,其时间源是 GPS 和原子钟,属于多时间源和物理时钟,同时它也采用了多点授时机制,就是说集群内有多个时间服务器都可以提供授时服务。Truetime 是 Google 的独门绝技,依赖于特定硬件设备的思路,不适用于开源软件。
TrueTime 会出现时光倒流,不只是 TrueTime,任何物理时钟都会存在时钟偏移甚至回拨。
单个物理时钟会产生误差,多点授时又会带来整体性的误差。
TrueTime 的优势:
- 高可靠高性能,多时间源和多授时点实现了完全的去中心化设计,不存在单点问题
- 支持全球化部署,客户端和时间服务器的距离可控,不会因为二者通讯延迟过长导致时钟失效
HLC
CockroachDB 和 YugabyteDB 也是以高性能高可靠和全球化部署为目标,因为 TrueTime 的限制,其使用了混合逻辑时钟(Hybrid Logical Clock,HLC)。同样是多时间源、多点授时,但时钟采用了物理时钟与逻辑时钟混合的方式。HLC 在实现机制上较复杂的,且和 TrueTime 同样有整体性的时间误差。
实现(CockroachDB)

方框是节点上发生的事件,方框内的三个数字依次是节点的本地物理时间(简称本地时间,Pt)、HLC 的高位(简称 L 值)和 HLC 的低位(简称 C 值)。
分析一:
事件 D2 发生时,首先取上一个事件 D1 的 L 值和本地时间比较。L 值等于 0,本地时间已经递增变为 1,取最大值,那么用本地时间作为 D2 的 L 值。高位变更了,低位要归零,所以 D2 的 HLC 就是 (1,0)。
分析二:
如果节点间有调用关系,计时逻辑会更复杂一点。我们看事件 B2,要先判断 B2 的 L 值,就有三个备选:本节点上前一个事件 B1 的 L 值当前本地时间调用事件 A1 的 L 值,A1 的 HLC 是随着函数调用传给 B 节点的。
这三个值分别是 0、1 和 10。按照规则取最大值,所以 B2 的 L 值是 10,也就是 A1 的 L 值,而 C 值就在 A1 的 C 值上加 1,最终 B2 的 HLC 就是 (10,1)。
分析三:
B3 事件发生时,发现当前本地时间比 B2 的 L 值还要小,所以沿用了 B2 的 L 值,而 C 值是在 B2 的 C 值上加一,最终 B3 的 HLC 就是 (10,2)。
TSO
在 NewSQL 架构中使用单时间源、单点授时的方式往往被称为 TSO(Timestamp Oracle),在 Proxy 架构风格中被称为全局事务管理器(Golobal Transcation Manager,GTM)。也就是一个单点递增的时间戳和全局事务号基本等效。
优点是实现简便,能够保证时钟单调递增,可以简化事务冲突时的设计。缺点是集群不能大范围部署,性能有上限。
TiDB、OceanBase、GoldenDB 和 TBase 等选择使用 TSO。
实现(TiDB)
中心化授时。
TiDB 的全局时钟是一个数值,由两部分构成,其中高位是物理时间,也就是操作系统的毫秒时间;低位是逻辑时间,是一个 18 位的数值。从存储空间看,1 毫秒最多可以产生 262,144 个时间戳(2^18),这个数字很大一般来说足够使用。
单点授时首先需要解决单点故障问题。TiDB 中提供授时服务的节点被称为 PD(Placement Driver)。多个 PD 节点构成一个 Raft 组,通过共识算法可以保证主节点宕机后马上选出新主,在短时间内恢复授时服务。
问题是如何保证新主产生的时间戳一定大于旧主。必须将旧主的时间戳持久化存储,存储必须高可靠,TiDB 使用了 etcd。不是每产生一个时间戳都要保存的,那样时间戳的产生速度直接与磁盘 I/O 能力相关存在瓶颈。TiDB 采用预申请时间窗口的方式,如下过程:

PD(主节点)系统时间是 103 毫秒,PD 向 etcd 申请了一个 可分配的时间窗口(可以通过参数指定,默认配置是 3 毫秒),所以该窗口起点是 PD 当前时间 103,时间窗口的终点就在 106 毫秒。写入 etcd 成功后,PD 将得到一个从 103 到 106 的 可分配时间窗口,这个时间窗口内 PD 可以使用系统的物理时间作为高位,拼接其在内存中累加的逻辑时间,对外分配时间戳。
这种设计意味着所有 PD 已分配时间戳的高位,即物理时间,永远小于 etcd 存储的最大值。如果 PD 主节点宕机,新主可以读取 etcd 中存储的最大值,在这个基础上申请新的 可分配时间窗口,这样新主分配的时间戳肯定大于旧主。
其次,客户端可以一次申请多个时间戳,但如果客户端缓存,多个客户端之间就不是严格单调递增的。
STP
这是一个小众的方案,如 巨杉的 STP(SequoiaDB Time Protoco),采用了单时间源、多点授时的方式,优缺点介于 HLC 和 TSO 之间。
STP 是独立于分布式数据库的授时方案,与巨杉其他角色没有必然联系。
STP 下的所有角色统称为 STP Node,分为两类:
- STP Server:STP 是独立于分布式数据库的授时方案。
- STP Client:按照固定的时间间隔,从 Primary Server 同步时间。 巨杉数据库的其他角色节点,如编目节点(CATALOG)、协调节点(COORD)和数据节点(DATA)等,都从本地的 STP Node 节点获得时间。
分片机制
分片策略
主要是:
- Hash(哈希)
- Range(范围)
Key 和 List 可以看做 Hash 和 Range 的特殊情况。其机制类似。
分片调度机制
分为两种:
- 静态:分片在节点上的分布基本固定,移动需要人工介入
- 动态:通过调度管理器基于算法在各节点之间自动地移动分片 分片机制与架构风格对应关系:
| 静态 | 动态 | |
|---|---|---|
| Hash | Proxy / NewSQL | N/A |
| Range | Proxy | NewSQL |
Hash分片
就是按照数据记录中指定关键字的 Hash 值将数据记录映射到不同的分片中。
Hash 计算会过滤掉数据原有的业务特性,可以保证数据非常均匀地分布到多个分片上,这是 其最大优势,且实现也很简洁。如果直接用节点数作为模,当系统节点数量变动时模也随之改变,就要重新 Hash 计算,会带来大规模的数据迁移,对于扩展性非常不友好。
于是引入了一致性 Hash。工业实践中应用一致性 Hash 算法,会引入虚拟节点,每个虚拟节点就是一个分片。一开始设定的分片数量决定了集群的最大规模,通常远大于初始集群节点。
节点和数据都通过 Hash 函数映射到 Hash 环上,数据按照顺时针找到最近的节点。
Range静态分片
Range 分片的特点恰恰是能够加入对于业务的预估,比如数据较多时可以更细分,数据较少时可以使用较粗的分类。
Range 分片的适用范围更加广泛。主要因为 Range 分片可以更高效地扫描数据记录,而 Hash 分片由于数据被打散,扫描操作的 I/O 开销更大。
Range动态分片
多数是用主键作为关键字来分片的,当然主键可以是系统自动生成的,也可以是用户指定的。
一般来说动态分片可以自动分裂和合并、根据访问压力调度分片,即存储均衡和访问压力均衡。
分片调度还应具有两项能力:
- 减少分布式事务:将频繁跨副本的事务数据转移到同一个节点,从而转换成本地事务。
- 缩短服务延时:调度到较近的数据中心。
数据复制协议
动态分片,满足高可靠的同时还要考虑元数据的多副本一致性,必须选择合适的复制协议。
如果搭建独立的、小规模元数据集群,则可以使用 Paxos 或 Raft 等协议,传播特点是广播。如果元数据存在工作节点上,数量较多则可以考虑 Gossip 协议,传播特点是谣言传播。
复制协议的选择和数据副本数量有很大关系:如果副本少,参与节点少,可以采用广播方式,也就是 Paxos、Raft 等协议;如果副本多,节点多,更适合采用 Gossip 协议。
Gossip协议
CockroachDB 采用了 P2P 架构,每个节点都要保存完整的元数据,这样节点规模就非常大,不适用广播机制。而 Gossip 协议的原理是谣言传播机制,每一次谣言都在几个人的小范围内传播,但最终会成为众人皆知的谣言。这种方式达成的数据一致性是 “最终一致性”,即执行数据更新操作后,经过一定的时间,集群内各个节点所存储的数据最终会达成一致。
虽然 Gossip 是最终一致性,但通过一些寻址过程中的巧妙设计,基于“最终一致性”的元数据也可以实现强一致性。
实现强一致性

- 节点 A 接到客户端的 SQL 请求,要查询数据表 T1 的记录,根据主键范围确定记录可能在分片 R1 上,而本地元数据显示 R1 存储在节点 B 上。
- 节点 A 向节点 B 发送请求。很不幸,节点 A 的元数据已经过时,R1 已经重新分配到节点 C。
- 此时节点 B 会回复给节点 A 一个非常重要的信息,R1 存储在节点 C。
- 节点 A 得到该信息后,向节点 C 再次发起查询请求,这次运气很好 R1 确实在节点 C。
- 节点 A 收到节点 C 返回的 R1。节点 A 向客户端返回 R1 上的记录,同时会更新本地元数据。 CockroachDB 在寻址过程中会不断地更新分片元数据,促成各节点元数据达成一致。
Raft协议
Raft 日志复制过程:
- Leader 收到客户端的请求。
- Leader 将请求内容(Log Entry)追加(Append)到本地 Log。
- Leader 将 Log Entry 发送给其他的 Follower。
- Leader 等待 Follower 的结果,如果大多数节点提交了该 Log,那么该 Log Entry 就是 Committed Entry,Leader 就可以将它应用(Apply)到本地的状态机。
- Leader 返回客户端提交成功。
- Leader 继续处理下一次请求。
顺序投票阻塞问题
当多事务并行操作时,由于前面的事务没有超过半数的响应,Leader 必须等待一个明确的失败信号,如通讯超时等,才能结束这次操作。因为有顺序投票的规则,会阻塞后续事务的进行。
优化方法(TiDB)
可以借鉴下 TiDB 的优化点:
- 批操作(Batch):Leader 缓存多个客户端请求,将一批日志批量发送给 Follower。减少通讯成本。
- 流水线(Pipeline):Leader 本地增加一个变量(称为 NextIndex),每次发送一个 Batch 后,更新 NextIndex 记录下一个 Batch 的位置,不等待 Follower 返回立刻发送下一个 Batch。当出现网络问题,Leader 重新调整 NextIndex 再次发送 Batch。
- 并行追加日志(Append Log Parallelly):Leader 将 Batch 发送给 Follower 的同时,并发执行本地的 Append 操作,可以减少部分开销。同时可以调整 Committed Entry 的判断规则,并行操作下,即使 Leader 没有 Append 成功,只要有半数以上的 Follower 节点 Append 成功,那就依然可以视为一个 Committed Entry,Entry 可以被 Apply。
- 异步应用日志(Asynchronous Apply):任何处于 Committed 状态的 Log Entry 都确保是不会丢失的。Apply 仅仅是为了保证状态能够在下次被正确地读取到,一般提交数据后不会马上读取,可以将 Apply 修改异步执行,同时改造读操作。
自增主键
特性
自增主键给开发人员提供了很大的便利。主键必须要保证唯一,且多数设计规范都会要求,主键不带有业务属性。如果数据库没有内置这个特性,应用开发人员就必须自己设计一套主键的生成逻辑,数据库原生提供的自增主键免去了这些工作量。
单体数据库自增主键
无法连续递增
事务发生冲突时,主键就会跳跃留下空洞。可以参考 MySQL 章节。
无法单调递增
当主键生成的速度能够满足应用系统的并发需求时,自增主键确实可以做到单调自增。但在高并发场景下,如果自增主键称为瓶颈,那么需要优化。
Oracle 数据库常见的优化方式就是由 Sequence 负责生成主键的高位,由应用服务器负责生成低位数字,拼接起来形成完整的主键。
这样只能保证全局唯一,但数据表中最终保存的主键不再是单调递增。
因此,在一个海量并发场景下,即使借助单体数据库的自增主键特性,也不能实现单调递增的主键。
自增主键的问题
分布式数据库中自增主键的问题更多,如:
- 在自增主键的产生环节
- 在自增主键的使用环节 可以发现自增主键的单调递增和全局时钟中的 TSO 很相似。
尾部热点
参考:https://www.cockroachlabs.com/blog/unpacking-competitive-benchmarks/
性能问题的根因是同时使用自增主键和 Range 分片。Range 分片有很多优势,使得其成为一个不能轻易放弃的选择。因此主流产品的默认方案是保持 Range 分片,放弃自增主键,转而用随机主键来代替。
随机主键
内置UUID
UUID 是由 32 个的 16 进制数字组成,长度是 128 位(16^32 = 2^128)。UUID 作为一种广泛使用标准,有多个实现版本,影响它的因素包括时间、网卡 MAC 地址、自定义 Namesapce 等等。
缺点是键值长度过长(128 位),存储和计算的代价都会增加。
内置Random ID
TiDB 默认是支持自增主键的,对未声明主键的表会提供了一个隐式主键 _tidb_rowid。因为这个主键大体上是单调递增的,所以也会出现尾部热点问题。
TiDB 除了提供了 UUID 函数,在 4.0 版本中还提供了一种解决方案 AutoRandom。TiDB 模仿 MySQL 的 AutoIncrement,提供了 AutoRandom 关键字用于生成一个随机 ID 填充指定列。
这个随机 ID 是一个 64 位整型,分为三个部分。
- 第一部分的符号位没有实际作用。
- 第二部分是事务开始时间,默认为 5 位,可以理解成事务时间戳的一种映射。
- 第三部分则是自增的序列号,使用其余位。
外置Snowflake
雪花算法(Snowflake)是 Twitter 公司分布式项目采用的 ID 生成算法。
生成的 ID 是一个 64 位的长整型,由四个部分构成:
- 第一部分是 1 位的符号位,并没有实际用处,主要为了兼容长整型的格式。
- 第二部分是 41 位的时间戳用来记录本地的毫秒时间。
- 第三部分是机器 ID,这里说的机器就是生成 ID 的节点,用 10 位长度给机器做编码,那意味着最大规模可以达到 1024 个节点(2^10)。
- 最后是 12 位序列,序列的长度直接决定了一个节点 1 毫秒能够产生的 ID 数量,12 位就是 4096(2^12)。 注意时钟回拨导致产生的 ID 重复,需要特殊处理。
关联查询
查询中的多表关联,也就是 join 操作,在分布式数据库中如何优化呢。
关联算法
常见的关联算法有三大类,分别是嵌套循环(Nested Loop Join)、排序归并(Sort-Merge Join)和哈希(Hash Join)。
嵌套循环连接算法
所有的嵌套循环算法都由内外两个循环构成,分别从两张表中顺序取数据。其中,外层循环表称为外表(Outer 表),内层循环表则称为内表(Inner 表)。算法过程是由遍历 Outer 表开始,Outer 表也称为驱动表。在最终得到的结果集中,记录的排列顺序与 Outer 表的记录顺序是一致的。
根据在处理环节上的不同,嵌套循环算法又可以细分为三种,分别是 Simple Nested-Loop Join(SNLJ)、Block Nested-Loop Join(BNJ)和 Index Lookup Join(ILJ)。
Simple Nested-Loop Join
SNLJ 是最简单粗暴的算法,有些资料会用 NLJ 指代 SNLJ。
SNLJ 执行过程:
- 遍历 Outer 表,取一条记录 r1;
- 遍历 Inner 表,对于 Inner 表中的每条记录,与 r1 做 join 操作并输出结果;
- 重复步骤 1 和 2,直至遍历完 Outer 表中的所有数据,就得到了最后的结果集。 性能问题:每次为了匹配 Outer 表的一条记录,都要对 Inner 表做一次全表扫描操作。而全表扫描的磁盘 I/O 开销很大。
Block Nested-Loop Join
BNJ 是对 SNLJ 的一种优化,改进点是减少 Inner 表的全表扫描次数。BNJ 的变化主要在于步骤 1,读取 Outer 表时不再只取一条记录,而是读取一个批次的 x 条记录加载到内存中。这样执行一次 Inner 表的全表扫描就可以比较 x 条记录。MySQL 中这个 x 对应一个叫做 Join Buffer 的设置项,直接影响了 BNJ 的执行效率。
与 SNLJ 相比,BNJ 虽然在时间复杂度都是 O(m*n)(m 和 n 分别是 Outer 表和 Inner 表的记录行数),但磁盘 I/O 的开销却明显降低了,所以效果优于 SNLJ
Index Lookup Join
SNLJ 和 BNJ 都是直接在数据行上扫描,并没有使用索引。所以这两种算法的磁盘 I/O 开销比较大。
Index Lookup Join(ILJ)在 BNJ 的基础上使用了索引,执行过程:
- 遍历 Outer 表,取一个批次的记录 ri;
- 通过连接键(Join Key)和 ri 可以确定对 Inner 表索引的扫描范围,再通过索引得到对应的若干条数据记录,记为 sj;
- 将 ri 的每一条记录与 sj 的每一条记录做 Join 操作并输出结果;
- 重复前三步,直到遍历完 Outer 表中的所有数据,就得到了最后结果集。 ILJ 的主要优化点很明显就是对 Inner 表进行索引扫描。BNJ 在 Inner 表上要做多次全表扫描成本最高,所以 Inner 表上使用索引的效果最显著,也就成为了算法的重点,而 Outer 表因为扫描结果集要放入内存中暂存,意味着它的记录数比较有限,索引带来的效果也就没有 Inner 表那么显著。
排序归并链接算法
排序归并算法就是 Sort-Merge Join(SMJ),也被称为 Merge Join。
SMJ 可以分为排序和归并两个阶段:
- 对 Outer 表和 Inner 表进行排序,排序的依据就是每条记录在连接键上的数值。
- 归并,两张表已经按照同样的顺序排列,Outer 表和 Inner 表各一次循环遍历就能完成比对工作。 SMJ 就是先要把两个数据集合变成两个数据序列(有序的数据单元),然后再做循环比对。计算成本是两次排序再加两次循环。所以选择 SMJ 的前提是表的记录本身就是有序的,否则成本较高。而索引天然有序,如果表的连接键刚好是索引列,那么 SMJ 就是三种嵌套循环算法中成本最低的,它的时间复杂度只有 O(m+n)。
哈希连接算法
哈希连接是一种分治思想,基本思想是取关联表的记录,计算连接键上数据项的哈希值,再根据哈希值映射为若干组,然后分组进行匹配。
常见的哈希连接算法有三种,分别是 Simple Hash Join、Grace Hash Join 和 Hybrid Hash Join。
Simple Hash Join
执行过程:
建立阶段(Build Phase) 选择一张表作为 Inner 表,对其中每条记录上的连接属性(Join Attribute)使用哈希函数得到哈希值,从而建立一个哈希表。在计算逻辑允许的情况下,建立阶段选择数据量较小的表作为 Inner 表,以减少生成哈希表的时间和空间开销。
探测阶段(Probe Phase) 另一个表作为 Outer 表,扫描它的每一行并计算连接属性的哈希值,与建立阶段生成的哈希表进行对比。当然哈希值相等不代表连接属性相等,需要再做一次判断,返回最终满足条件的记录。
这里做了非常理想化的假设,即 Inner 表形成的哈希表小到能够放入内存中。但实际上哈希表也有可能超过内存容量。所以引入了 Grace Hash Join 算法。
Grace Hash Join
GHJ 中的 Grace 并不是指某项技术,而是首个采用该算法的数据库名称。Grace 将哈希表分块缓存在磁盘上。
执行过程:
- Inner 表的记录会根据哈希值分成若干个块(Bucket)写入磁盘,每个 Bucket 必须小于内存容量。Outer 表也按照同样的方法被分为若干 Bucket 写入磁盘,但大小并不受到内存容量限制。
- 和 SHJ 类似,先将 Inner 表的 Bucket 加载到内存,再读取 Outer 表对应 Bucket 的记录进行匹配,所有 Inner 表和 Outer 表的 Bucket 都读取完毕后,就得到了最终的结果集。
Hybrid Hash Join
也就是混合哈希,字面上是指 Simple Hash Join 和 Grace Hash Join 的混合。实际上主要是针对 Grace Hash Join 的优化,内存够用下,可以将 Inner 表的第一个 Bucket 和 Outer 表的第一个 Bucket 都保留在内存中,这样建立阶段一结束就可以进行匹配,节省了先写入磁盘再读取的两次 I/O 操作。
总体来说,哈希连接的核心思想和排序归并很相似,都是对内外表的记录分别只做一次循环。哈希连接算法不仅能够处理大小表关联,对提升大表之间关联的效率也有明显效果,但限制条件就是适用于等值连接。
并行框架
大小表关联(复制表)
大小表关联时,可以把小表复制到相关存储节点,这样全局关联就被转换为一系列的本地关联,再汇总起来就得到了最终结果。
具体实现有静态和动态两种方式。
静态方式
创建表时直接使用关键字将表声明为复制表,每个节点上都会保留一份数据副本。当与大表关联时,计算节点就可以将关联操作下推到每个存储节点进行。很多分布式数据库,比如 TBase、TDSQL 等,都支持定义复制表。
动态方式
动态方式也称为“小表广播”,不需要人工预先定义,在关联发生时系统自行处理。当关联的某张表足够小时,在整个集群中分发不会带来太大的网络开销,系统就将其即时地复制到相关的数据节点上,实现本地关联。
大表关联(重分布)
复制表解决了大小表关联的问题,还剩下最棘手的大表间关联,它的解决方案通常就是重分布。
例如,A、B 两张大表,c 作为关联字段。
| |
那么可能引起两种不同的重分布操作:
- c 是 A 表的分区键,但不是 B 表的分区键,则 B 表按照 c 做重分布,推送到 A 的各个分片上,实现本地关联。
- 两张表的分区键都不是 c,则两张表都要按照 c 做重分布,然后在多个节点上再做本地关联。(执行代价很高) 重分布的思想和 MapReduce、Spark 等并行计算引擎一致,基本等同于 Shuffle 操作:
- shuffle 阶段:分别将两个表按照连接键进行分区,将相同连接键的记录重分布到同一节点,数据就会被分配到尽量多的节点上,增大并行度。
- hash join 阶段:每个分区节点上的数据单独执行单机 hash join 算法。
查询执行引擎
火山模型
火山模型(Volcano Model)也称为迭代模型(Iterator Model),是最著名的查询执行模型。1990 年提出,长期流行的查询执行模型,主流的 OLTP 数据库 Oracle、MySQL 都采用了这种模型。但面对海量数据时,火山模型有 CPU 使用率低的问题,性能有待提升。
火山模型中,一个查询计划会被分解为多个代数运算符(Operator)。每个 Operator 就是一个迭代器,都要实现一个 next() 接口,通常包括三个步骤:
- 调用子节点 Operator 的 next() 接口,获取一个元组(Tuple);
- 对元组执行 Operator 特定的处理;
- 返回处理后的元组。
优缺点
火山模型的优点是处理逻辑清晰,每个 Operator 只要关心自己的处理逻辑即可,耦合性低。但是它的缺点也非常明显,主要是两点:
- 虚函数调用次数过多,造成 CPU 资源的浪费。
- 数据以行为单位进行处理,不利于发挥现代 CPU 的特性。
运算符融合
最简单的方法就是减少执行过程中 Operator 的函数调用。通常来说 Project 和 Filter 都是常见的 Operator,在很多查询计划中都会出现。OceanBase1.0 就将两个 Operator 融合到了其它的 Operator 中。这样做有两个好处:
- 降低了整个查询计划中 Operator 的数量,也就简化了 Operator 间的嵌套调用关系,最终减少了虚函数调用次数。
- 单个 Operator 的处理逻辑更集中,增强了代码局部性能力,更容易发挥 CPU 的分支预测能力。 火山模型仍有一些优化空间,比如运算符融合,可以适度减少虚函数调用,但提升空间有限。学术界提出的两种优化方案是向量化和代码生成。
分支预测能力
分支预测是指 CPU 执行跳转指令时的一种优化技术。当出现程序分支时 CPU 需要执行跳转指令,在跳转的目的地址之前无法确定下一条指令,就只能让流水线等待,这就降低了 CPU 效率。为了提高效率,设计者在 CPU 中引入了一组寄存器,用来专门记录最近几次某个地址的跳转指令。
当下次执行到这个跳转指令时,就可以直接取出上次保存的指令,放入流水线。等到真正获取到指令时,如果证明取错了则推翻当前流水线中的指令,执行真正的指令。
向量化模型
向量化模型就是一系列向量化运算符组成的执行模型。向量化模型首先在 OLAP 数据库和大数据领域广泛使用,配合列式存储取得很好的效果。虽然 OLTP 数据库的场景不适于列式存储,但将其与行式存储结合也取得了明显的性能提升。
向量化模型与火山模型的最大差异就是,其中的 Operator 是向量化运算符,是基于列来重写查询处理算法的。简单来说向量化模型是由一系列支持向量化运算的 Operator 组成的执行模型。
向量化模型依然采用了拉取式模型。和火山模型的唯一区别就是 Operator 的 next() 函数每次返回的是一个向量块,而不是一个元组。向量块是访问数据的基本单元,由固定的一组向量组成,这些向量和列 / 字段一一对应。
向量处理背后的主要思想是,按列组织数据和计算,充分利用 CPU,把从多列到元组的转化推迟到较晚的时候执行。这种方法在不同的操作符间平摊了函数调用的开销。
向量化模型首先在 OLAP 数据库中采用,与列式存储搭配使用可以获得更好的效果,例如 ClickHouse。
这里的分布式数据库都是面向 OLTP 场景的,不能直接使用列式存储。但是可以采用折中的方式来实现向量化模型,即在底层的 Operator 中完成多行到向量块的转化,上层的 Operator 都是以向量块作为输入。这样改造后即使是与行式存储结合,仍然能够显著提升性能。在 TiDB 和 CockroachDB 的实践中,性能提升可以达到数倍甚至数十倍。
代码生成
与向量化模型并列的另一种高效查询执行引擎就是 代码生成。代码生成的全称是以数据为中心的代码生成(Data-Centric Code Generation),也被称为编译执行(Compilation)。
代码生成消除了火山模型中的大量虚函数调用,让大部分指令可以直接从寄存器取数,极大地提高了 CPU 的执行效率。
代码生成是现代编译器与 CPU 结合的产物,也可以大幅提升查询执行效率。代码生成的基础逻辑是,针对性的代码在执行效率上必然优于通用运算符嵌套。代码生成根据算法会被划分成多个在 Pipeline 执行的单元,提升 CPU 效率。代码生成有不同的粒度,包括整体代码生成和表达式代码生成,粒度越大实现难度越大。
Reference
https://www.cs.princeton.edu/courses/archive/fall10/cos597B/papers/percolator-osdi10.pdf
https://cse.buffalo.edu/~demirbas/publications/hlc.pdf
9.2 - MySQL
Introduction
MySQL
9.2.1 - 01.SQL查询流程
本文介绍 MySQL 的逻辑架构。如一条 SQL 查询语句的执行过程等。
逻辑架构
MySQL 的逻辑架构图

大体来说,MySQL 可以分为Server 层和存储引擎层两部分。
Server层
Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
存储引擎层
存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。
现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。创建表时如果不指定引擎类型,默认使用的就是 InnoDB。指定引擎:
| |
不同存储引擎的表数据存取方式不同,支持的功能也不同。
连接器
连接器负责跟客户端建立连接、获取权限、维持和管理连接。连接命令一般写法:
| |
连接命令 mysql 是客户端命令,完成 TCP 握手后,连接器会验证身份。如果身份不对会返回“Access denied for user”的错误。身份通过后,该连接的权限判断都依赖此时读取的权限(不会热更新权限)。
创建 2 个连接,如果都是空闲,使用其中一个连接 show processlist
| |
可以看到 Command 列显示“Sleep”的行是一个空闲的连接。
自动断开
客户端长时间没有动静,连接器会自动断开。由参数 wait_timeout 控制,默认 8 小时。
长连接
长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。
建立连接的过程通常是比较复杂的,建议使用中尽量减少建立连接的动作,也就是尽量使用长连接。
但是全部使用长连接后,你可能会发现,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。
解决方案:
1、定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
2、使用 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
查询缓存
连接建立完成后,就可以执行 select 语句。执行逻辑就会来到第二步:查询缓存。
MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,这个 value 就会被直接返回给客户端。
如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。如果查询命中缓存,MySQL 不需要执行后面的复杂操作,直接返回结果,效率会很高。
但是建议不要使用查询缓存,往往弊大于利。
1、缓存需要语句完全相等,包括参数。
2、表更新后就会失效 因此,只有在表更新频率不高,查询语句完全一致的情况下,可以手动开启缓存,其他一律关闭。
( 注意:mysql8之后,取消了缓存功能。)
MySQL 也提供了这种“按需使用”的方式。将参数 query_cache_type 设置成 DEMAND,这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定:
| |
分析器
如果没有命中查询缓存,就要开始真正执行语句了。首先需要对 SQL 语句做解析。
词法分析
分析器先会做“词法分析”。解析字符串分别是什么,代表什么。
词法分析的特征:
| 词性 | 内容 |
|---|---|
| 关键字 | select、from、where |
| 标志符 | id、name、age |
| 操作符 | =、>、< |
| 常量 | 1、2 |
语法分析
然后做“语法分析”。语法分析会根据词法分析获得的词来匹配语法规则,最终生成一个抽象语法树,每个词作为语法树的叶子节点出现。
根据语法规则,判断 SQL 语句是否满足语法。如果语句不对,就会收到“You have an error in your SQL syntax”的错误提醒。
语义分析
对语法树进行有效性检查,检查语法树中对应的表、列、函数、表达式是否有对应的元数据,将抽象语法树转换为逻辑执行计划(关系代数表达式)。
抽象语法树表达的寓意还仅仅限制在能够保证应用的 SQL 语句符合 SQL 标准的规范,但是对于 SQL 语句的内在含义还需要做有效性检查。
优化器
经过了分析器,MySQL 就知道要做什么。在开始执行之前,还要先经过优化器的处理。
优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。
执行器
MySQL 通过分析器知道要做什么,通过优化器知道该怎么做,就进入了执行器阶段,开始执行语句。
首先判断执行权限。
然后继续执行。
如果没有索引,慢查询日志会有一个 row_examined 的字段,表示执行器获取到的数据行数,不是真正的扫描行数。
9.2.2 - 02.日志
本文介绍 MySQL 的日志,同时会介绍一个 SQL 更新语句的执行过程。
背景
SQL 更新语句和查询语句的大致流程类似,首先通过连接器连接数据库,然后分析器会通过词法和语法解析知道这是更新语句(跟这个表有关的查询缓存会失效)。
与查询流程不一样的是,更新流程还涉及两个重要的日志模块:redo log(重做日志)和 binlog(归档日志)
名词介绍
物理/逻辑日志
物理日志记录的是修改页的的详情,逻辑日志记录的是操作语句。物理日志恢复的速度快于逻辑日志。
redo log
redo log 是事务日志、物理机日志
先写日志再写磁盘的过程就是 WAL 技术(Write-Ahead Logging)。
当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log 里面,并更新内存,此时更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。
InnoDB 的 redo log 是固定大小的。如果 redo log 满了,就会将 redo log 中一部分的记录更新到磁盘,然后将这些记录从 redo log 中删除腾出空间。
redo log 大小固定,从头开始写,写到末尾会重头开始写。

write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。
有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe。
redo log作用
保证事务的原子性和持久性。
redo log 是重做日记,属于 InnoDB 引擎的日志。
前滚操作:具备 crash-safe 能力,提供断电重启时解决事务丢失数据问题。
提高性能:先写 redo log记录更新。当等到有空闲线程、内存不足、redo log 满了时刷脏。写 redo log 是顺序写入,刷脏是随机写,节省的是随机写磁盘的 IO 消耗(转成顺序写),所以性能得到提升。此技术称为 WAL 技术:Write-Ahead Logging,它的关键点就是先写日记磁盘,再写数据磁盘。
redo log写入机制
redo log 可能存在的三种状态,对应下图中的三个颜色块:

[MySQL redo log 存储状态]
这三种状态分别是:
1、存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
2、写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
3、持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。
日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。
redo log持久化
为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值(默认为1):
0 表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
1 表示每次事务提交时都将 redo log 直接持久化到磁盘;
2 表示每次事务提交时都只是把 redo log 写到 page cache。
InnoDB 有一个后台线程,每隔 1 秒就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。
注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。
实际除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中:
1、redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。
注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
2、并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。
假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。
两阶段提交时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。
每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 时就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。
通常 MySQL 的“双 1”配置,指 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。
组提交(group commit)
MySQL 看到的 TPS 是每秒两万的话,每秒就会写四万次磁盘。但用工具测试,磁盘能力也就两万左右,怎么能实现两万的 TPS?
日志逻辑序列号(log sequence number,LSN)是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。
LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。

[redo log组提交]
上图所示,三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。
由图得到:
1、trx1 是第一个到达的,会被选为这组的 leader;
2、等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
3、trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
4、此时 trx2 和 trx3 就可以直接返回了。
所以一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。
在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。
为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。
两阶段提交的过程:
1、写入 redo log,处于 prepare 阶段
2、写 binlog
3、提交事务,处于 commit 阶段
实际上,第 2 步写 binlog 是分为两步的:
1)先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
2)调用 fsync 持久化。
MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是两阶段提交的细化过程:
1、redo log prepare; write
2、binlog; write
3、redo log prepare; fsync
4、binlog; fsync
5、redo log commit; write
不过通常情况下上面第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。
如果想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delay 或 binlog_group_commit_sync_no_delay_count 来实现。
1、binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
2、binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
每次提交事务都要写 redo log 和 binlog,WAL 机制主要得益于两个方面:
1、redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
2、组提交机制,可以大幅度降低磁盘的 IOPS 消耗。
如果出现了 IO 性能瓶颈,可以考虑以下几种方法:
1、设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
2、将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。风险:主机掉电会丢 binlog 日志。
3、将 innodb_flush_log_at_trx_commit 设置为 2。风险:主机掉电的时候会丢数据。
(不建议把 innodb_flush_log_at_trx_commit 设置成 0。因为这表示 redo log 只保存在内存,MySQL 本身异常重启也会丢数据风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据,相比之下风险更小。)
FAQ
1、执行 update 语句以后,再去执行 hexdump 命令直接查看 ibd 文件内容,没有看到数据有改变?
可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。
2、为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?
这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。
而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。
3、事务执行期间,没到提交阶段,如果发生 crash,redo log 丢了会不会导致主备不一致?
不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。
4、binlog 写完盘后发生 crash,这时还没给客户端答复就重启。等客户端再重连进来,发现事务已经提交成功了,是 bug?
不是 bug。实际上数据库的 crash-safe 保证的是:
1、如果客户端收到事务成功的消息,事务一定持久化了;
2、如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
3、如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以。
联想更极端的情况,如果整个事务都提交了,从库也收到 binlog 并执行了,但主库和客户端网络断开,导致事务成功的包无法返回,那么这种也算事务成功执行的。
flush
当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。
将脏页数据写到磁盘中。称作刷脏页(flush)。
触发flush
1、redo log 空间满了。
2、系统内存不足。
需要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,需要先将脏页写到磁盘
3、系统“空闲”时。
4、MySQL正常关闭时。
下次启动时可以直接从磁盘读数据,启动速度更快。
针对第 1 种情况,要尽量避免,对性能影响较大。
针对第 2 种情况是常态。InnoDB 用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:
1、还没有使用的;
2、使用了并且是干净页;
3、使用了并且是脏页。
InnoDB 的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。
但是出现以下两种情况,会明显影响性能。
1、一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
2、日志写满,更新全部堵住,写性能跌为 0。
所以,InnoDB 需要有控制脏页比例的机制,来尽量避免上面的这两种情况。
flush控制策略
1、设置磁盘 IO 能力
通过 innodb_io_capacity 参数设置磁盘的 IO 能力,建议设置成磁盘的 IOPS。
fio 工具可以测试磁盘的 IOPS
| |
2、脏页比例
参数 innodb_max_dirty_pages_pct 是脏页比例上限,默认值是 75%。
脏页比例是通过 Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total 得到的,命令参考:
从mysql5.7.6开始information_schema.global_status已经开始被舍弃,为了兼容性,此时需要打开 show_compatibility_56
| |
查看脏页比例命令:
| |
3、刷脏页速度
InnoDB 会根据当前的脏页比例(假设为 M),算出一个范围在 0 到 100 之间的数字 F1(M),计算这个数字的伪代码类似这样:
| |
InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,假设为 N。InnoDB 根据 N 算出一个范围在 0 到 100 之间的数字,计算公式可以 F2(N)。这个算法比较复杂, N 越大值越大。
根据上述算得的 F1(M) 和 F2(N) 两个值,取其中较大的值记为 R,之后引擎就可以按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度。
4、刷新相邻页面策略
一旦一个查询请求需要在执行过程中先 flush 掉一个脏页时,该查询可能比平时慢。
而 MySQL 中的一个机制,可能会让查询更慢:
在准备刷一个脏页时,如果该数据页旁边的数据页刚好是脏页,就会把这个“邻居”一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,即对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。
InnoDB 中,innodb_flush_neighbors 参数用来控制该行为,值为 1 时会有上述的“连坐”机制,开启脏页相邻淘汰,值为 0 时表示关闭脏页相邻淘汰。(MySQL 8.0中,默认 0)
找“邻居”这个优化在机械硬盘时代是很有意义的,可以减少很多随机 IO。机械硬盘的随机 IOPS 一般只有几百,相同的逻辑操作减少随机 IO 就意味着系统性能的大幅度提升。
SSD IOPS 值有上千,建议设置该参数为 0。
binlog
MySQL 整体来看分为两块:Server 层主要做的是功能层面的事情;引擎层负责存储相关的具体事宜。redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。
为什么会有两份日志
最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统,也就是 redo log 来实现 crash-safe 能力。
binlog写入机制
写入逻辑:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。
一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。涉及到 binlog cache 的保存问题。
系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。
事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。

[binlog写盘状态]
可以看到,每个线程有自己的 binlog cache,但是共用同一份 binlog 文件。上图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下认为 fsync 才占磁盘的 IOPS。
binlog持久化
write 和 fsync 的时机,是由参数 sync_binlog 控制的:
1、sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
2、sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
3、sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。
出现 IO 瓶颈时,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,常见将其设置为 100~1000 中的某个数值。
将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。
binlog日志格式
statement:记录执行的 SQL 语句。
row:会记录行的内容(包括 id),记两条,更新前和更新后都有。
mixed:以上两种混合,可以利用 statment 格式的优点,同时又避免了数据不一致的风险。
mixed 格式存在的场景:
1、有些 statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。原因:如果 where 条件中有多个索引,主备库在执行这条语句时,选择的索引可能不同执行结果也就不同。
2、row 格式的缺点是很占空间。比如一个 delete 语句删掉 10 万行数据,用 statement 的话就是一个 SQL 语句被记录到 binlog 中,占用几十个字节的空间。但如果用 row 格式就要把这 10 万条记录都写到 binlog 中。这样不仅会占用更大的空间,同时写 binlog 也要耗费 IO 资源,影响执行速度。
3、折中方案,MySQL 会判断 SQL 语句是否可能引起主备不一致,如果可能就用 row 格式,否则就用 statement 格式。
现在越来越多的场景要求把 MySQL 的 binlog 格式设置成 row。比如恢复数据。row 格式记录了操作前后的数据,可以直接恢复。
由 delete、insert 或者 update 语句导致的数据操作错误,需要恢复到操作之前状态的情况,也时有发生。MariaDB 的 Flashback 工具就是基于这种原理来回滚数据的。
mix 格式举例:
| |
查看 binlog 日志是 statement 格式。 mysqlbinlog 工具查看,原来 binlog 在记录 event 的时候,多记了一条命令:SET TIMESTAMP=1546103491。它用 SET TIMESTAMP 命令约定了接下来的 now() 函数的返回时间。
因此,得出结论:重放 binlog 数据,将 statement 语句直接拷贝出来执行有很大风险。
标准做法:
| |
(将 master.000001 文件里面从第 2738 字节到第 2973 字节中间这段内容解析出来,放到 MySQL 去执行。)
undo log
(逻辑日志)——记录相反的sql语句
1、保证事务的一致性
2、InnoDB的MVCC
存储结构
回滚段与undo页
undo日志的结构由回滚段和undo页组成
- undo日志中的空间划分为一个个的段,称为回滚段(rollback segment),共有128个段,每个段中有1024个undo页
- undo页可以重复使用,如果当前事务写入的记录小于页空间的3/4,那其他事务可以继续写入。
回滚段与事务
- 每个事务只能用一个回滚段,一个回滚段可以在同一时刻服务于多个事务
- 事务开始时会制定一个回滚段,事务进行中当数据被修改时,原始数据会比复制到回滚段
undo页重用
开启的事务需要写 undo log 时,先去 undo log segment 中申请 undo 页(MySQL中默认一页是16k),如果每个事务分配一个页非常浪费(除非事务写数据非常大),TPS为1000,每秒需要 1000*16k=16M 的空间,每分钟需要 1G,很多空间都被浪费了。所以undo页被设计成重用的。
事务提交时并不会立刻删除undo页,因为重用使得该undo页中可能混杂着其它事务的undo log,undo log在commit后会被放到一个链表中,undo页使用空间如果小于3/4,表示可以重用而不会被回收,其它事务的undo log可以记录在当前undo页之后。
undo log是离散的,因此清理对应的磁盘空间效率不高。
类型
插入页:在进行insert操作时产生的日志记录,由于insert只有事务本身可见,其他事务不可见,所以事务提交后insert日志页可以直接删除,不需要purge操作。
更新页:在进行删除或更新时产生的日志记录。更新页需要参与多版本机制(MVCC),所以事务提交后一般不会删除,会放入链表,等待purge线程清除。
数据更新流程
Buffer Pool、Redo、Undo日志的情况下,更新一次数据流程:
- 发起更新数据请求
- Buffer Pool中没有数据则从磁盘中加载
- 记录undo log
- 执行器更新数据
- 写入 redo log Buffer
- 写入 redo log 到文件
- 写入 binlog 到文件
日志对比
redo log与binlog对比
| redo log | binlog | |
|---|---|---|
| 日志类型 | 物理日志 | 逻辑日志 |
| 文件大小 | 大小固定 | 可通过参数max_binlog_size设置每个binlog文件的大小 |
| 实现方式 | 由innodb引擎层实现,不是所有引擎都有 | 由service层实现,所有引擎都可以使用 |
| 记录 | 采用循环写的方式记录,当写到结尾时,会回到开头循坏写日志 | 通过追加的方式记录,当文件大小大于给定值后,后续的日志会记录到新的文件上 |
| 适用场景 | 适用于崩溃恢复(crash-safe) | 适用于主从复制和数据恢复 |
update语句执行流程
update 语句:
| |
1、执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。 2、执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
3、引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
4、执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
5、执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。
update 语句执行流程图

两阶段提交
更新内存后引擎层写 Redo log 将状态改成 prepare 为预提交第一阶段,Server 层写 Binlog,将状态改成 commit为提交第二阶段。两阶段提交可以确保 Binlog 和 Redo log 数据一致性。
数据备份同步
1、找到最近时间的全量备份;
2、基于备份的时间点重放 binlog。
容灾恢复过程
1、判断 redo log 是否完整,如果判断是完整(commit)的,直接用 Redo log 恢复
2、如果 redo log 只是预提交 prepare 但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 Redo log,用 redo log 恢复,不完整就回滚事务,丢弃数据。
只有在 redo log 状态为 prepare 时,才会去检查 binlog 是否存在,否则只校验 redo log 是否是 commit 就可以啦。
怎么检查 binlog:一个完整事务 binlog 结尾有固定的格式。
1、statement 格式的 binlog,最后会有 COMMIT;
2、row 格式的 binlog,最后会有一个 XID event。
另外,在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用来验证 binlog 内容的正确性。
两阶段提交分析
1、先写 redo log 后写 binlog。
假设 redo log 写完,binlog 还没写完。系统异常重启,那么系统仍然可以根据 redo log 将数据恢复。
但是 binlog 没有写完就 crash 了,没有该更新记录。因此之后备份数据恢复的时候,自然也没有该更新记录。使用 binlog 恢复临时库就会发生与原库数据不同的问题。
2、先写 binlog 后写 redo log
假设 binlog 写完,redo log 还没写完。系统异常重启,因为 redo log 中没有该更新记录,库中的记录还是原来的值。但是 binlog 已经存在更新记录,所以之后用 binlog 恢复临时库该记录就是新的值,与原库数据不同。
鉴此,两阶段提交可以保证 redo log 和 binlog 数据的一致性。即两个日志都可以表示事物的提交状态。
change buffer
当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InnoDB 会将这些更新操作直接缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页再更新了。
「change buffer 主要节省的则是随机读磁盘的 IO 消耗」,下次查询读取数据页时用上 change buffer 中的记录即可。
change buffer 可以持久化,在内存中有拷贝,也会被写入到磁盘上。
将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会明显提升。而且,数据读入内存是需要占用 buffer pool 的,所以还能够避免占用内存,提高内存利用率。
merge
将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。
cb使用条件
唯一索引的更新就不能使用 change buffer,实际上也只有普通索引可以使用。
因为对于唯一索引来说,所有的更新操作都要先判断操作是否违反唯一性约束。必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用 change buffer 了。
cb限制
change buffer 用的是 buffer pool 里的内存,因此不能无限增大。
change buffer 的大小,可以通过参数 innodb_change_buffer_max_size 来动态设置。这个参数设置为 50 的时候,表示 change buffer 的大小最多只能占用 buffer pool 的 50%。
cb插入数据流程
目标页在内存中
- 对于唯一索引,目标页在内存中,直接判断唯一索引值是否冲突,然后插入数据,语句执行结束
- 对于普通索引,找到目标页中插入数据的位置,插入该记录,语句执行结束
目标页不在内存中
- 对于唯一索引,需要将数据页读入内存,判断是否冲突,然后插入数据,语句执行结束
- 对于普通索引,将记录插入 change buffer,语句执行结束
cb使用场景
首先,change buffer 只限于用在普通索引的场景下,而不适用于唯一索引。
其次,因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。
因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。
相反,如果一个业务是写入数据后立即查询,会触发 merge 过程,随机访问 IO 的次数不会减少,且会增加 change buffer 的维护代价。这种业务下 change buffer 反而起到了副作用。
日志延伸
change buffer 和 redo log
redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。
redo log 与 change buffer (含磁盘持久化) 这 2 个机制,不同之处在于优化了整个变更流程的不同阶段。
先不考虑这二者机制,简化抽象一个更新 (insert、update、delete) 流程:
1、从磁盘读取待变更的行所在的数据页,读入内存页中
2、对内存页中的行,执行变更操作
3、将变更后的数据页,写入至数据磁盘中
其中,流程中的步骤 1 涉及随机读磁盘 IO;步骤 3 涉及随机写磁盘 IO;刚好对应 change buffer 和 redo log。
根据以上流程得出结论:
1、change buffer 机制,优化了步骤 1——避免了随机读磁盘 IO ,将不在内存中的数据页的操作写入 change buffer 中,而不是将数据页从磁盘读入内存页中
2、redo log 机制, 优化了步骤 3——避免了随机写磁盘 IO,将随机写磁盘,优化为了顺序写磁盘(写 redo log,确保 crash-safe)
change buffer 机制不是一直会被应用到,仅当待操作的数据页当前不在内存中,需要先读磁盘加载数据页时,change buffer 才有用武之地。而 redo log 机制,为了保证 crash-safe 会一直被用到。
checkpoint
redo log中带有checkpoint,用来高效的恢复数据.
最佳实践
flush案例
一个内存配置为 128GB、innodb_io_capacity 设置为 20000 的大规格实例,正常会建议将 redo log 设置成 4 个 1GB 的文件。
如果配置时不慎将 redo log 设置成了 1 个 100M 的文件,会发生的情况及原因?
每次事务提交都要写 redo log,如果设置太小很快就会被写满,write pos 一直追着 checkpoint。
此时系统不得不停止所有更新去推进 checkpoint。
现象:磁盘压力很小,数据库出现间歇性的性能下跌。
生产库非双1场景
通常 MySQL 的“双 1”配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。
非双1场景有如下:
1、业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
2、备库延迟,为了让备库尽快赶上主库。
3、用备份恢复主库的副本,应用 binlog 的过程,跟第 2 个场景类似。
4、批量导入数据的时候。
一般情况下,把生产库改成“非双 1”配置,是设置 innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。
日志FAQ
red log和binlog关联
它们有一个共同的数据字段,叫 XID。崩溃恢复的时候,会按顺序扫描 redo log:
1、如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
2、如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。
两阶段提交的必要性
处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢复,为什么还要两阶段提交?
两阶段提交是经典的分布式系统问题,并不是 MySQL 独有的。
两阶段提交的必要性,是事务的持久性问题。
对于 InnoDB 引擎来说,如果 redo log 提交完成了,事务就不能回滚(如果这还允许回滚,就可能覆盖掉别的事务的更新)。而如果 redo log 直接提交,然后 binlog 写入的时候失败,InnoDB 又回滚不了,数据和 binlog 日志又不一致了。两阶段提交就是为了进可攻退可守。
redo log一般设置多大
redo log 太小会导致很快就被写满,不得不强行刷 redo log,这样 WAL 机制的能力就发挥不出来。
现在常见的几个 TB 的磁盘的话,可以直接将 redo log 设置为 4 个文件、每个文件 1GB 。
redo log最终落盘
正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新过来的还是从 buffer pool 更新过来的呢?
涉及 redo log 里面是什么。
redo log 记录了"在某个数据页上做了什么修改",而不是"这个数据修改后最新的值"。因此是需要先把磁盘的数据读入内存再执行 redo log 中的内容。
1、正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与 redo log 毫无关系;
2、崩溃恢复场景中,InnoDB 如果判断一个数据页可能在崩溃恢复时丢失了更新,会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第 1 种情况的状态。
redo log buffer是什么
先修改内存,还是先写 redo log 文件?
插入数据的过程中,生成的日志都得先保存起来,但又不能在还没 commit 的时候就直接写到 redo log 文件里。
所以 redo log buffer 就是一块内存,用来先存 redo 日志。即在执行更新操作时(未 commit),数据的内存被修改了,redo log buffer 也写入了日志。
但是,真正把日志写到 redo log 文件(文件名是 ib_logfile+ 数字),是在执行 commit 语句的时候做的。
Reference
9.2.3 - 02.日志扩展-主从架构
本文介绍基于 MySQL 的日志的主从架构
主从同步
从库和备库在概念上其实差不多。有的地方会把在 HA 过程中被选成新主库的,称为备库,其他的称为从库。
从库readonly
建议把从库设置成只读(readonly)模式。有以下几个考虑:
1、一些运营类查询语句可能会被放到从库上去查,设置为只读可以防止误操作;
2、防止切换逻辑有 bug,比如切换过程中出现双写,造成主从不一致;
3、可以用 readonly 状态,来判断节点的角色。
把从库设置成只读了,如何跟主库保持同步更新?readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程拥有超级权限。
主从同步流程

[主从同步流程图]
从库 B 跟主库 A 之间维持了一个长连接。主库 A 内部有一个线程,专门用于服务从库 B 的这个长连接。一个事务日志同步的完整过程是这样的:
1、在从库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
2、在从库 B 上执行 start slave 命令,这时候从库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
3、主库 A 校验完用户名、密码后,开始按照从库 B 传过来的位置,从本地读取 binlog,发给 B。
4、从库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
5、sql_thread 读取中转日志,解析出日志里的命令,并执行。
由于多线程复制方案的引入,sql_thread 后来演化成为了多个线程。
循环复制问题
(建议把参数 log_slave_updates 设置为 on,表示从库执行 relay log 后生成 binlog)
双节点双主库可能存在循环使用 binlog 同步数据的情况,可以通过 server id 解决,有如下规定:
1、规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主从关系;
2、一个从库接到 binlog 并在重放过程中,生成与原 binlog 的 server id 相同的新的 binlog;
3、每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。
但这个机制其实并不完备,在某些场景下还是有可能出现死循环。比如:
1、在一个主库更新事务后,用命令 set global server_id=x 修改了 server_id。等日志再传回来的时候,发现 server_id 跟自己的 server_id 不同就只能执行了。
2、三个节点复制的场景,如 server id 是 B 节点产生的,binlog 传给 A 执行,然后 A 又和 A1 形成双 M 结构,就会出现循环复制。
数据迁移时会遇到这种三节点循环复制的问题,可以临时在迁移后的节点上(比如上面的 A 或 A1 )执行:
| |
这样这个节点收到日志后就不会再执行。数据迁移完后,再执行下面的命令把改回来:
| |
主从延迟
MySQL 要提供高可用能力,只有最终一致性是不够的。
主从切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。
延迟时间计算
与数据同步有关的时间点主要包括以下三个:
1、主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;
2、之后传给从库 B,我们把从库 B 接收完这个 binlog 的时刻记为 T2;
3、从库 B 执行完成这个事务,我们把这个时刻记为 T3。
主从延迟就是同一个事务,在从库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。
在从库上执行 show slave status 命令,返回结果里面会显示 seconds_behind_master,用于表示当前从库延迟了多少秒。seconds_behind_master 的计算方法是这样的:
1、每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
2、从库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。
主从库机器的系统时间设置不一致,不会影响该值。因为,从库连接到主库时,会通过执行 SELECT UNIX_TIMESTAMP() 函数来获得当前主库的系统时间。如果发现主库的系统时间与自己不一致,从库在执行 seconds_behind_master 计算的时候会自动扣掉这个差值。
主从延迟原因
在网络正常的时候,日志从主库传给从库所需的时间是很短的,即 T2-T1 的值是非常小的。也就是说,网络正常情况下,主从延迟的主要来源是从库接收完 binlog 和执行完这个事务之间的时间差。
主从延迟最直接的表现是,从库消费中转日志(relay log)的速度,比主库生产 binlog 的速度要慢。可能是由以下原因导致的。
从库机器性能差
比如将众多从库都放在同一台机器上。
更新请求对 IOPS 的压力,在主库和从库上是无差别的。做这种部署时,一般都会将从库设置为“非双 1”的模式。
但实际上更新过程中也会触发大量的读操作。所以,当从库主机上的多个从库都在争抢资源的时候,就可能会导致主从延迟了。
一般比较常见是主从库机器相同,因为主从可能发生切换,从库随时可能变成主库,所以主从库选用相同规格的机器,并且做对称部署。
从库压力大
由于主库直接影响业务,使用起来会比较克制,反而忽视了从库的压力控制。结果就是,从库上的查询耗费了大量的 CPU 资源,影响了同步速度,造成主从延迟。如一些运营后台需要的分析语句。
处理方法:
1、一主多从。除了从库外,可以多接几个从库,让这些从库来分担读的压力。
2、通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。
大事务
主库上必须等事务执行完成才会写入 binlog,再传给从库。所以如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10 分钟。比如一个典型的大事务场景:不要一次性地用 delete 语句删除太多数据。
大表DDL
处理方案就是计划内的 DDL,建议使用 gh-ost 方案
从库并行复制能力
应对策略

[主从切换流程-双 M 结构]
可靠性优先策略
在 [主从切换流程-双 M 结构] 下,从状态 1 到状态 2 切换的详细过程是这样的:
1、判断从库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
2、把主库 A 改成只读状态,即把 readonly 设置为 true;
3、判断从库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止;
4、把从库 B 改成可读写状态,也就是把 readonly 设置为 false;
5、把业务请求切到从库 B。
这个切换流程,一般是由专门的 HA 系统来完成的,暂时称之为可靠性优先流程。

[可靠性优先主从切换流程]
这个切换流程中是有不可用时间的。因为在步骤 2 之后,主库 A 和从库 B 都处于 readonly 状态,也就是说这时系统处于不可写状态,直到步骤 5 完成后才能恢复。
可用性优先策略
如果强行把步骤 4、5 调整到最开始执行,也就是说不等主从数据同步,直接把连接切到从库 B,并且让从库 B 可以读写,那么系统几乎就没有不可用时间了。
这个切换流程暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。分析如下:
表:
| |
这个表定义了一个自增主键 id,初始化数据后,主库和从库上都是 3 行数据。接下来继续在表 t 上执行两条插入语句的命令,依次是:
| |
假设现在主库上其他的数据表有大量的更新,导致主从延迟达到 5 秒。在插入一条 c=4 的语句后,发起了主从切换。 binlog 为 mixed 格式流程

[可用性优先策略流程] (binlog_format=mixed)
切换流程:
1、步骤 2 中,主库 A 执行完 insert 语句,插入了一行数据(4,4),之后开始进行主从切换。
2、步骤 3 中,由于主从之间有 5 秒的延迟,所以从库 B 还没来得及应用“插入 c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。
3、步骤 4 中,从库 B 插入了一行数据(4,5),并且把这个 binlog 发给主库 A。
4、步骤 5 中,从库 B 执行“插入 c=4”这个中转日志,插入了一行数据(5,4)。而直接在从库 B 执行的“插入 c=5”这个语句,传到主库 A,就插入了一行新数据(5,5)。
最后的结果就是,主库 A 和从库 B 上出现了两行不一致的数据。可以看到,这个数据不一致,是由可用性优先流程导致的。
binlog 为 row 格式流程:

[可用性优先策略流程] (binlog_format=row)
因为 row 格式在记录 binlog 的时候,会记录新插入的行的所有字段值,所以最后只会有一行不一致。而且,两边的主从同步的应用线程会报错 duplicate key error 并停止。也就是说,这种情况下,从库 B 的 (5,4) 和主库 A 的 (5,5) 这两行数据,都不会被对方执行。
结论:
1、使用 row 格式的 binlog 时,数据不一致的问题更容易被发现。而使用 mixed 或者 statement 格式的 binlog 时,数据不一致很难发现且随着时间推移可能会造成更多数据逻辑不一致。
2、主从切换的可用性优先策略会导致数据不一致。大多数情况下建议使用可靠性优先策略。一般对数据服务来说,数据的可靠性一般优于可用性的。
可用性优先级更高的场景:
有个库的作用是记录操作日志。如果数据不一致可以通过 binlog 来修补,而这个短暂的不一致也不会引发业务问题。同时,业务系统依赖于这个日志写入逻辑,如果库不可写会导致线上的业务操作无法执行。
MySQL 的高可用性,依赖于主从延迟。主从延迟的时间越小,出现故障的时候,服务需要恢复的时间就越短,可用性就越高。
从库并行复制

[主从同步流程图]
谈到主从的并行复制能力,要关注上图中黑色的两个箭头。一个箭头代表了客户端写入主库,另一箭头代表的是从库上 sql_thread 执行中转日志(relay log)。如果用箭头的粗细来代表并行度的话,那么真实情况就如图所示,第一个箭头要明显粗于第二个箭头。
主库影响并发度就是各种锁。由于 InnoDB 引擎支持行锁,除了所有并发事务都在更新同一行(热点行)这种极端场景外,对业务并发度的支持还是很友好。
而日志在从库执行,就是图中从库上 sql_thread 更新数据的逻辑。如果用单线程就会导致从库应用日志不够快,造成主从延迟。
sql_thread多线程
在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主从延迟问题。MySQL 多线程复制的演进过程经历了好几个版本。
多线程复制机制,都是要把 [主从同步流程图] 中只有一个线程的 sql_thread,拆成多个线程,基本都符合下面的模型:

[sql_thread多线程模型]
coordinator 就是原来的 sql_thread,它不再直接更新数据,只负责读取中转日志和分发事务。真正更新日志的变成了 worker 线程。而 work 线程的个数,就是由参数 slave_parallel_workers 决定。根据经验把参数设置为 8~16 之间最好(32 核物理机),毕竟从库还有可能要提供读查询,不要占用过多 CPU。
事务能不能按照轮询的方式分发给各个 worker,否则 workder 独立执行速度快慢不一。同一个事物的多个 SQL 语句也不能分配给多个 worker 执行,否则破坏事务的隔离性(查询可能看到事务执行一半的结果)。
所以coordinator 在分发时,需要满足以下两个基本要求:
1、不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中。
2、同一个事务不能被拆开,必须放到同一个 worker 中。
各个版本的多线程复制,都遵循了这两条基本原则。
通用并行复制策略
官方 MySQL 5.5 版本是不支持并行复制的,第三方开发了按表分发策略和按行分发策略。
按表分发策略
按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。因为数据是存储在表里的,所以按表分发,可以保证两个 worker 不会更新同一行,如果有跨表的事务,要把两张表放在一起考虑。

[按表并行复制程模型]
每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的“执行队列”里的事务所涉及的表。hash 表的 key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。
在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。
每个事务在分发的时候,跟所有 worker 的冲突关系包括以下三种情况:
1、如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
2、如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
3、如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。
这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。
按行分发策略
要解决热点表的并行复制问题,需要按行并行复制的方案。核心思路是:如果两个事务没有更新相同的行,它们在从库上可以并行执行。显然这个模式要求 binlog 格式必须是 row。
判断一个事务 T 和 worker 是否冲突,用的就规则就不是“修改同一个表”,而是“修改同一行”。
key,就必须是“库名 + 表名 + 唯一键的值”,考虑到唯一索引,基于行的策略,事务 hash 表中还需要考虑唯一键,即 key 应该是“库名 + 表名 + 索引名字 + 索引值”。
举例:
| |
SQL 语句:
| Session A | Session B |
|---|---|
| update t6 set a=6 where id=1; | |
| update t6 set a=1 where id=2; |
这两个事务要更新的行的主键值不同,但是如果它们被分到不同的 worker,就有可能 Session B 的语句先执行。此时 id=1 的行的 a 的值还是 1,就会报唯一键冲突。
表 t1 上执行 update t1 set a=1 where id=2 语句,binlog 里面记录了整行的数据修改前各个字段的值,和修改后各个字段的值。
因此,coordinator 在解析这个语句的 binlog 的时候,这个事务的 hash 表就有三个项:
1、key=hash_func(db1+t1+"PRIMARY"+2), value=2; 这里 value=2 是因为修改前后的行 id 值不变,出现了两次。
2、key=hash_func(db1+t1+"a"+2), value=1,表示会影响到这个表 a=2 的行。(修改前 a=2)
3、key=hash_func(db1+t1+"a"+1), value=1,表示会影响到这个表 a=1 的行。(修改后 a=1)
相比于按表并行分发策略,按行并行策略在决定线程分发时,需要消耗更多的计算资源。两个方案其实都有一些约束条件:
1、要能够从 binlog 里面解析出表名、主键值和唯一索引的值。也就是说,主库的 binlog 格式必须是 row;
2、表必须有主键;不能有外键。表上如果有外键,级联更新的行不会记录在 binlog 中,这样冲突检测就不准确。
按行分发的策略有两个问题:
1、耗费内存。比如一个语句要删除 100 万行数据,这时 hash 表就要记录 100 万个项。
2、耗费 CPU。解析 binlog,然后计算 hash 值,对于大事务成本很高。
因此实现这个策略时会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过 10 万行),就暂时退化为单线程模式,退化过程的逻辑如下:
1、coordinator 暂时先 hold 住这个事务;
2、等待所有 worker 都执行完成,变成空队列;
3、coordinator 直接执行这个事务;
4、恢复并行模式。
官方并行复制策略
MySQL5.6并行复制策略
只是支持的粒度是按库并行,用于决定分发策略的 hash 表里,key 就是数据库名。
这个策略的并行效果,取决于压力模型。如果在主库上有多个 DB,并且各个 DB 的压力均衡,使用这个策略的效果会很好。相比于按表和按行分发,这个策略有两个优势:
1、构造 hash 值的时候很快,只需要库名;而且一个实例上 DB 数也不会很多,不会出现需要构造 100 万个项这种情况。
2、不要求 binlog 的格式。因为 statement 格式的 binlog 也可以很容易拿到库名。
但是,如果主库上的表都放在同一个 DB 里面,这个策略就没有效果;或者如果不同 DB 的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果。
MariaDB并行复制策略
MariaDB 的并行复制策略利用了 redo log 组提交 (group commit) 优化的特性 :
1、能够在同一组里提交的事务,一定不会修改同一行;
2、主库上可以并行执行的事务,从库上也一定是可以并行执行的。
实现上的流程:
1、在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
2、commit_id 直接写到 binlog 里面;
3、传到从库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
4、这一组全部执行完成后,coordinator 再去取下一批。
这个策略出来的时候相当惊艳。因为之前业界的思路都是在“分析 binlog,并拆分到 worker”上。而 MariaDB 的这个策略,目标是“模拟主库的并行模式”。
但是这个策略有一个问题,它并没有实现“真正的模拟主库并发度”这个目标。主库一组事务 commit 时,下一组事务是同时处于“执行中”状态的。从库上执行时,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。
另外,这个方案很容易被大事务拖后腿。假设 trx2 是一个超大事务,那么在从库应用的时候,trx1 和 trx3 执行完成后,就只能等 trx2 完全执行完成,下一组才能开始执行。这段时间只有一个 worker 线程在工作,是对资源的浪费。
该策略仍然是一个很好的创新,对原系统的改造非常少,实现很优雅。
MySQL5.7并行复制策略
参数 slave-parallel-type ( show variables like 'slave_parallel_type )来控制并行复制策略:
1、配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
2、配置为 LOGICAL_CLOCK,表示类似 MariaDB 的策略。不过 MySQL 5.7 这个策略,针对并行度做了优化。
首先,同时处于“执行状态”的所有事务,不能并行执行。因为里面可能有由于锁冲突而处于锁等待状态的事务。MariaDB 策略的核心是“所有处于 commit”状态的事务可以并行。事务处于 commit 状态,表示已经通过了锁冲突的检验了。
两阶段提交过程中,其实不用等到 commit 阶段,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。
因此,MySQL 5.7 并行复制策略的思想是:
1、同时处于 prepare 状态的事务,在从库执行时是可以并行的;
2、处于 prepare 状态的事务,与处于 commit 状态的事务之间,在从库执行时也是可以并行的。
binlog 的组提交有两个参数:
1、binlog_group_commit_sync_delay 表示延迟多少微秒后才调用 fsync;
2、binlog_group_commit_sync_no_delay_count 表示累积多少次以后才调用 fsync。
这两个参数是用于故意拉长 binlog 从 write 到 fsync 的时间,以此减少 binlog 的写盘次数。在 MySQL 5.7 的并行复制策略里,它们可以用来制造更多的“同时处于 prepare 阶段的事务”。这样可以从库复制的并行度。
即这两个参数,既可以“故意”让主库提交得慢些,又可以让从库执行得快些。在 MySQL 5.7 处理从库延迟时,可以考虑调整这两个参数值,来达到提升从库复制并发度的目的。
MySQL5.7.22并行复制策略
这个版本增加了一个新的并行复制策略,基于 WRITESET 的并行复制。新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略,参数值:
1、COMMIT_ORDER 表示根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
2、WRITESET 表示对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。
3、WRITESET_SESSION 表示在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在从库执行的时候,要保证相同的先后顺序。
为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。
跟前面介绍的基于 MySQL 5.5 版本的按行分发的策略差不多。不过 MySQL 官方的实现还是有很大的优势:
1、writeset 是在主库生成后直接写入到 binlog 里面的,这样在从库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;
2、不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;
3、由于从库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的。
因此,MySQL 5.7.22 的并行复制策略在通用性上还是有保证的。当然对于“表上没主键”和“外键约束”的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型。
主备切换
一主多从的架构下,假设 A 为主库,A1 为从库,B、C、D 为从库,当 A 断电后,A1 会成为新的主库,从库 B、C、D 也要重新指向 A1。
基于位点的主备切换
把节点 B 设置成节点 A’的从库的时候,需要执行一条 change master 命令:
| |
参数: 1、MASTER_HOST、MASTER_PORT、MASTER_USER 和 MASTER_PASSWORD 四个参数,分别代表了主库 A’的 IP、端口、用户名和密码。
2、最后两个参数 MASTER_LOG_FILE 和 MASTER_LOG_POS 表示,要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步。这个位置就是同步位点,也就是主库对应的文件名和日志偏移量。
同步位点
原来节点 B 是 A 的从库,本地记录的也是 A 的位点。但是相同的日志,A 的位点和 A’的位点是不同的。因此从库 B 切换时,需要先“找同步位点”。
本质是重放binlog,如果太靠后可能会丢记录,稍微往前点的可以经过判断跳过已经执行的。
一种取同步位点的方法是这样的:
1、等待新主库 A’把中转日志(relay log)全部同步完成;
2、在 A’上执行 show master status 命令,得到当前 A’上最新的 File 和 Position;
3、取原主库 A 故障的时刻 T;
4、用 mysqlbinlog 工具解析 A’的 File,得到 T 时刻的位点。
| |

[mysqlbinlog 部分输出结果]
图中 end_log_pos 后面的值“123”,表示的就是 A1 这个实例,在 T 时刻写入新的 binlog 的位置。然后,我们就可以把 123 这个值作为 $master_log_pos ,用在节点 B 的 change master 命令里。
该值不准确的原因:
假设 T 时刻主库 A 已经执行完成了一个 insert 语句插入了一行数据 R,并且已经将 binlog 传给了 A1 和 B,然后在传完的瞬间主库 A 主机掉电。
此时系统状态是:
1、在从库 B 上,由于同步了 binlog, R 这一行已经存在;
2、在新主库 A1上, R 这一行也已经存在,日志是写在 123 这个位置之后的;
3、在从库 B 上执行 change master 命令,指向 A1 的 File 文件的 123 位置,就会把插入 R 这一行数据的 binlog 又同步到从库 B 去执行。
从库 B 的同步线程就会提示主键冲突,然后停止同步。
主动跳过错误
通常情况下,在切换任务时,要先主动跳过这些错误,有两种常用的方法。一种做法是:
| |
切换过程中,可能会不止重复执行一个事务,需要在从库 B 刚开始接到新主库 A1 时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务。 另一种方式是:设置 slave_skip_errors 参数,直接设置跳过指定的错误。
执行主备切换时,有两类错误经常遇到:
1、1062 错误是插入数据时唯一键冲突;
2、1032 错误是删除数据时找不到行。
因此可以把 slave_skip_errors 设置为 “1032,1062”。
注意:主备间的同步关系建立完成,并稳定执行一段时间之后,需要把这个参数设置为空,以免之后真的出现了主从数据不一致也跳过。
GTID
虽然上面的方法最终可以建立从库 B 和新主库 A1 的主从关系,但操作都很复杂,而且容易出错。MySQL 5.6 版本引入了 GTID,彻底解决了这个困难。
GTID 的全称是 Global Transaction Identifier,即全局事务 ID,是一个事务在提交的时候生成的唯一标识。由两部分组成,格式是:
| |
1、server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值; 2、gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。
官方定义格式:
| |
source_id 就是 server_uuid,transaction_id 容易造成误解,两个都是递增,不同点是事务 id 自增但不一定连续,因为会被回滚,而 gno 在提交时分配,所以是连续递增的。 在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。这个 GTID 有两种生成方式,而使用哪种方式取决于 session 变量 gtid_next 的值:
1、默认值 automatic,MySQL 就会把 server_uuid:gno 分配给这个事务。
1)记录 binlog 时,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’;
2)把这个 GTID 加入本实例的 GTID 集合。
2、gtid_next 是一个指定的 GTID 的值,比如通过 set gtid_next='current_gtid’指定为 current_gtid,有两种可能:
1)如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略;
2)如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1。
注意,一个 current_gtid 只能给一个事务使用。事务提交后要执行下一个事务,就要执行 set 命令,把 gtid_next 设置成另外一个 gtid 或者 automatic。这样每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。
举例说明:
| |

[初始化数据的binlog]
查看 binlog,事务的 BEGIN 之前有一条 SET @@SESSION.GTID_NEXT 命令。这时,如果实例 X 有从库,那么将 CREATE TABLE 和 insert 语句的 binlog 同步过去执行的话,执行事务之前就会先执行这两个 SET 命令, 这样被加入从库的 GTID 集合的,就是上图的这两个 GTID。
如果从库主键冲突,可以执行:
| |
前三条语句的作用,是通过提交一个空事务,把这个 GTID 加到实例 X 的 GTID 集合中。然后 show master status 可以看到 Executed_Gtid_set 已经加入了这个 GTID。
再执行 start slave 命令让同步线程执行起来时,虽然实例 X 上还是会继续执行实例 Y 传过来的事务,但是由于“xxxx:10”已经存在于实例的 GTID 集合中了,就会直接跳过这个事务,也就不会再出现主键冲突的错误。
set gtid_next=automatic 的作用是“恢复 GTID 的默认分配行为”,如果之后有新的事务再执行,就还是按照原来的分配方式,继续分配 gno=3。
基于GTID的主备切换
GTID 模式下,从库 B 要设置为新主库 A1 的从库的语法如下:
| |
master_auto_position=1 就表示这个主备关系使用的是 GTID 协议。可以看到难以指定的 MASTER_LOG_FILE 和 MASTER_LOG_POS 参数,已经不需要指定了。 实例 A1 的 GTID 集合记为 set_a,实例 B 的 GTID 集合记为 set_b。
实例 B 上执行 start slave 命令,取 binlog 的逻辑:
1、实例 B 指定主库 A1,基于主备协议建立连接。
2、实例 B 把 set_b 发给主库 A1。
3、实例 A1 算出 set_a 与 set_b 的差集,也就是所有存在于 set_a 但不存在于 set_b 的 GTID 集合,判断 A1 本地是否包含了这个差集需要的所有 binlog 事务。
1)如果不包含,表示 A1 已经把实例 B 需要的 binlog 给删掉了,直接返回错误;
2)如果确认全部包含,A1 从自己的 binlog 文件中找出第一个不在 set_b 的事务,发给 B;
4、之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。
主备切换,若选择 A1 作为新的主库,必须要包含从库 B 的全部内容;如果存在事务 B 中有,A1 没有,则 A1 不能成为新的主库。
主备切换的流程:
由于不需要找位点了,从库 B、C、D 只需要分别执行 change master 命令指向实例 A1 即可。(找位点在 A1 内部完成了)
之后系统就由新主库 A1 写入,主库 A1 生成的 binlog 中的 GTID 集合格式是:server_uuid_of_A1:1-M。
GTID和在线DDL
假设两个互为主备关系的库是实例 X 和实例 Y,且当前主库是 X,并且都打开了 GTID 模式。这时的主备切换流程可以变成下面这样:
1、在实例 X 上执行 stop slave。
2、在实例 Y 上执行 DDL 语句。注意,这里并不需要关闭 binlog。
3、执行完成后,查出这个 DDL 语句对应的 GTID,并记为 server_uuid_of_Y:gno。
4、到实例 X 上执行以下语句序列:
| |
目的:既可以让实例 Y 的更新有 binlog 记录,同时也可以确保不会在实例 X 上执行这条更新。 5、按照之前的流程继续执行。
读写分离
读写分离的主要目标就是分摊主库的压力。一般有两种架构:
1、客户端直连方案
需要了解后端部署细节,在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。
2、Proxy
客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。对后端维护团队的要求会更高。Proxy 需要有高可用架构。整体相对比较复杂。
不论使用哪种架构,都会碰到由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库,就有可能读到刚刚的事务更新之前的状态。本文称作“过期读”。
主从延迟不能 100% 避免的。下面介绍一下处理“过期读”的方案
强制走主库方案
强制走主库方案其实就是,将查询请求做分类:
1、对于必须要拿到最新结果的请求,强制将其发到主库上。
2、对于可以读到旧数据的请求,才将其发到从库上。
sleep方案
主库更新后,读从库之前先 sleep 一下。
具体的方案就是,类似于执行一条 select sleep(1) 命令。这个方案的假设是:大多数情况下主备延迟在 1 秒之内,做一个 sleep 可以有很大概率拿到最新的数据。
更靠谱的方案
客户端将用户数据的数据直接展示在页面上,而不是真正的去请求后端数据库。等到卖家再刷新页面去查询时,已经过了一段时间,相当于 sleep 了。
判断无延迟方案
确保从库无延迟,通常有三种做法:
1、 show slave status 结果里的 seconds_behind_master 参数的值(秒级),可以用来衡量主备延迟时间的长短。
每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0。如果不等于,就必须等到为 0 才能执行查询请求。
2、对比位点确保主从无延迟
1)Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
2)Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是从库执行的最新位点。
如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 这两组值完全相同,就表示接收到的日志已经同步完成。
3、对比 GTID 集合确保主备无延迟
1)Auto_Position=1 ,表示这对主从关系使用了 GTID 协议。
2)Retrieved_Gtid_Set,是从库收到的所有日志的 GTID 集合
3)Executed_Gtid_Set,是从库所有已经执行完成的 GTID 集合。
如果这两个集合相同,也表示从库接收到的日志都已经同步完成。
对比位点和对比 GTID 这两种方法,都要比判断 seconds_behind_master 是否为 0 更准确。
配合semi-sync方案
引入半同步复制,也就是 semi-sync replication。semi-sync 做了这样的设计:
1、事务提交的时候,主库把 binlog 发给从库;
2、从库收到 binlog 后,发回给主库一个 ack,表示收到了;
3、主库收到 ack 后,才能给客户端返回“事务完成”的确认。
如果启用了 semi-sync,就表示所有给客户端发送过确认的事务,都确保了从库已经收到了这个日志。
semi-sync 配合前面关于位点的判断,能够确定在从库上执行的查询请求,可以避免过期读。
但是,semi-sync+ 位点判断的方案,只对一主一从的场景是成立的。在一主多从场景中,主库只要等到一个从库的 ack,就开始给客户端返回确认。
判断同步位点的方案还有另外一个潜在问题,即:如果在业务更新的高峰期,主库的位点或者 GTID 集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况。
semi-sync 配合判断主备无延迟的方案,存在两个问题:
1、一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
2、在持续延迟的情况下,可能出现过度等待的问题。
扩展延伸
如果主库掉电时,有些 binlog 还来不及发给从库,会不会导致系统数据丢失?
如果使用的是普通的异步复制模式,就可能会丢失,但 semi-sync 就可以解决这个问题。
等主库位点方案
| |
这条命令的逻辑: 1、它是在从库执行的;
2、参数 file 和 pos 指的是主库上的文件名和位置;
3、timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。
这个命令正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务。
还会返回一些其他结果,包括:
1、如果执行期间,从库同步线程发生异常则返回 NULL;
2、如果等待超过 N 秒,就返回 -1;
3、如果刚开始执行时,发现已经执行过这个位置则返回 0。
如果主库频繁写入事务,并不需要获取当前事务对应的pos,执行完事务之后,获取的pos肯定是要比当前事务的pos的位置要大,只要在从库上面执行,master_pos_wait 返回大于等于 0 的值就说明事务已经在从库执行了。查询的结果就是正确的。
等位点执行流程如下:
1、trx1 事务更新完成后,马上执行 show master status 得到当前主库执行到的 File 和 Position;
2、选定一个从库执行查询语句;
3、在从库上执行 select master_pos_wait(File, Position, 1);
4、如果返回值是 >=0 的正整数,则在这个从库执行查询语句;
5、否则,到主库执行查询语句。
等GTID方案
| |
这条命令的逻辑是: 1、等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
2、超时返回 1。
等位点的方案中,执行完事务后要主动去主库执行 show master status。而 MySQL 5.7.6 版本开始,允许在执行完更新类事务后,把这个事务的 GTID 返回给客户端,这样等 GTID 的方案就可以减少一次查询。
等 GTID 执行流程如下:
1、trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;
2、选定一个从库执行查询语句;
3、在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);
4、如果返回值是 0,则在这个从库执行查询语句;
5、否则,到主库执行查询语句。
将参数 session_track_gtids 设置为 OWN_GTID,MySQL 在执行事务后,就会在返回包中带上 GTID,然后通过 API 接口 mysql_session_track_get_first 从返回包解析出 GTID 的值即可。API 接口
最佳实践
从库45度延迟
一般现在的数据库运维系统都有从库延迟监控,其实就是在从库上执行 show slave status,采集 seconds_behind_master 的值。
假设维护的一个从库,它的延迟监控的图像类似下图,是一个 45°斜向上的线段,可能是什么原因导致呢?如何确认?

从库的同步在这段时间完全被堵住了。产生这种现象典型的场景主要包括两种:
1、大事务(包括大表 DDL、一个事务操作很多行);
2、从库起了一个长事务,比如
| |
然后就不动了。这时候主库对表 t 做了一个加字段操作,即使这个表很小,这个 DDL 在从库应用的时候也会被堵住,也不能看到这个现象。 注意:从库跟不上主库的更新速度(并行复制)会导致主从延迟,但不会表现为这种标准的呈 45 度的直线。
从库并行复制策略选择
如果主库都是单线程压力模式,在从库追主库的过程中,binlog-transaction-dependency-tracking 应该选用什么参数?
应该将这个参数设置为 WRITESET。
由于主库是单线程压力模式,所以每个事务的 commit_id 都不同,那么设置为 COMMIT_ORDER 模式的话,从库也只能单线程执行。
同样地,由于 WRITESET_SESSION 模式要求在从库应用日志的时候,同一个线程的日志必须与主库上执行的先后顺序相同,也会导致主库单线程压力模式下退化成单线程复制。所以,应该将 binlog-transaction-dependency-tracking 设置为 WRITESET。
GTID下主库binlog丢失
GTID 模式下,从库执行 start slave 命令后,主库发现需要的 binlog 已经被没了,导致主备创建不成功,如何处理?
1、如果业务允许主从不一致的情况,那么可以在主库上先执行 show global variables like ‘gtid_purged’,得到主库已经删除的 GTID 集合,假设是 gtid_purged1;然后先在从库上执行 reset master,再执行 set global gtid_purged =‘gtid_purged1’;最后执行 start slave,就会从主库现存的 binlog 开始同步。binlog 缺失的那一部分,数据在从库上就可能会有丢失,造成主从不一致。
2、如果需要主从数据一致,最好还是通过重新搭建从库来做。
3、如果有其他的从库保留有全量的 binlog ,可以把新的从库先接到这个保留了全量 binlog 的从库,追上日志后,如果有需要再接回主库。
4、如果 binlog 有备份的情况,可以先在从库上应用缺失的 binlog,然后再执行 start slave。
GTID等位点读写分离做DDL
如果使用 GTID 等位点的方案做读写分离,在对大表做 DDL 的时候会怎么样?
假设,这条语句在主库上要执行 10 分钟,提交后传到从库就要 10 分钟(典型的大事务)。那么,在主库 DDL 之后再提交的事务的 GTID,去从库查的时候,就会等 10 分钟才出现。这样,这个读写分离机制在这 10 分钟之内都会超时,然后走主库。
这种预期内的操作,应该在业务低峰期的时候,确保主库能够支持所有业务查询,然后把读请求都切到主库,再在主库上做 DDL。等从库延迟追上以后,再把读请求切回从库。
需要关注大事务对等位点方案的影响。另外使用 gh-ost 方案来解决这个问题也是不错的选择。
判断主库故障
主从切换有两种场景,一种是主动切换,一种是被动切换。而其中被动切换往往是因为主库出问题了,由 HA 系统发起的。
判断主库故障的方法:
select 1 判断
select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。
可以通过设置 innodb_thread_concurrency 参数控制 InnoDB 的并发线程上限,使用多个语句如 select sleep(100) from t; 占用查询,新建会话发现 select 1 可以返回,但是其他 SQL 语句却因为分配不到连接线程被阻塞。
该参数默认为 0,表示不限制并发线程数量。建议设置为 64~128。
注意并发连接和并发查询,并不是同一个概念。show processlist 中看到的几千个连接,指的就是并发连接。而“当前正在执行”的语句,才是并发查询。并发连接数达到几千个影响并不大,就是多占一些内存而已。并发查询太高才是 CPU 杀手。
线程进入锁等待以后,并发线程的计数会减一,也就是等行锁(也包括间隙锁)的线程是不算在这个参数里面的。
查表判断
为了能够检测 InnoDB 并发线程数过多导致的系统不可用情况,我们需要找一个访问 InnoDB 的场景。一般做法是在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行:
| |
问题:更新事务要写 binlog,而一旦 binlog 所在磁盘的空间占用率达到 100%,那么所有的更新语句和事务提交的 commit 语句就都会被堵住。但是,系统这时候还是可以正常读数据的。
更新判断
既然要更新,就要放个有意义的字段,常见做法是放一个 timestamp 字段,用来表示最后一次执行检测的时间。这条更新语句类似于:
| |
节点可用性的检测都应该包含主库和从库。如果用更新来检测主库的话,那么从库也要进行更新检测。 但从库的检测也是要写 binlog 的。因为一般会把数据库 A 和 B 的主备关系设计为双 M 结构,所以在备库 B 上执行的检测命令,也要发回给主库 A。
但是如果主库 A 和备库 B 都用相同的更新命令,就可能出现行冲突,也就是可能会导致主备同步停止。所以现在看来 mysql.health_check 这个表就不能只有一行数据了。
为了让主备之间的更新不产生冲突,可以在 mysql.health_check 表上存入多行数据,并用 A、B 的 server_id 做主键。
| |
由于 MySQL 规定了主库和备库的 server_id 必须不同(否则创建主备关系的时候就会报错),这样就可以保证主从库各自的检测命令不会发生冲突。 但是更新判断还是有“判定慢”的问题。根本原因是上面说的所有方法,都是基于外部检测的,有一个随机性的天然问题。外部检测都需要定时轮询,可能需要等到下一个检测发起执行语句时,才有可能发现问题。
比如 IO 利用率 100% 表示系统的 IO 是在工作的,每个请求都有机会获得 IO 资源,执行自己的任务。而检测使用的 update 命令,需要的资源很少,所以可能在拿到 IO 资源的时候就可以提交成功,并且在超时时间 N 秒未到达之前就返回给了检测系统。
内部统计
针对磁盘利用率,如果 MySQL 可以告诉我们内部每一次 IO 请求的时间,那判断数据库是否出问题的方法就很可靠。
MySQL 5.6 版本以后提供的 performance_schema 库,就在 file_summary_by_event_name 表里统计了每次 IO 请求的时间。
file_summary_by_event_name 表里有很多行数据,先看看 event_name='wait/io/file/innodb/innodb_log_file’这一行。

这一行表示统计的是 redo log 的写入时间,第一列 EVENT_NAME 表示统计的类型。
接下来的三组数据,显示的是 redo log 操作的时间统计。
第一组五列,是所有 IO 类型的统计。其中,COUNT_STAR 是所有 IO 的总次数,接下来四列是具体的统计项, 单位是皮秒;前缀 SUM、MIN、AVG、MAX,顾名思义指的就是总和、最小值、平均值和最大值。
第二组六列,是读操作的统计。最后一列 SUM_NUMBER_OF_BYTES_READ 统计的是,总共从 redo log 里读了多少个字节。
第三组六列,统计的是写操作。
最后的第四组数据,是对其他类型数据的统计。在 redo log 里,可以认为就是对 fsync 的统计。
在 performance_schema 库的 file_summary_by_event_name 表里,binlog 对应的是 event_name = "wait/io/file/sql/binlog"这一行。各个字段的统计逻辑,与 redo log 的各个字段完全相同。
如果打开所有的 performance_schema 项,性能大概会下降 10% 左右。
如果要打开 redo log 的时间监控,可以执行这个语句:
| |
可以通过 MAX_TIMER 的值来判断数据库是否出问题。比如可以设定阈值,单次 IO 请求时间超过 200 毫秒属于异常,然后使用类似下面这条语句作为检测逻辑:
| |
发现异常后获取到需要的信息。可通过下面这条语句把之前的统计信息清空:
| |
判断主库故障小结
使用非常广泛的 MHA(Master High Availability),默认使用 select 1 方法。
MHA 中的另一个可选方法是只做连接,就是 “如果连接成功就认为主库没问题”。但选择这个方法的很少。
建议是优先考虑 update 系统表,然后再配合增加检测 performance_schema 的信息。
9.2.4 - 03.表
表
表的组成
一个 InnoDB 表包含两部分,即:表结构定义和数据。
在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里。而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了。表结构定义占用的空间很小。
表数据
表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:
1、设置为 OFF 表示:表的数据放在系统共享表空间,也就是跟数据字典放在一起;
2、设置为 ON 表示:每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。
从 MySQL 5.6.6 版本开始,默认值就是 ON 。
建议无论哪个版本都将这个值设置为 ON。因为一个表单独存储为一个文件更容易管理,而且不需要这个表时,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。
数据删除流程
InnoDB 里的数据都是用 B+ 树的结构组织的。

[B+ 树索引示意图]
假设删除 R4 记录,InnoDB 引擎只会把 R4 记录标记为删除。如果之后要插入一个 ID 在 300 和 600 之间的记录时,可能会复用该位置。但是,磁盘文件的大小并不会缩小。
InnoDB 的数据是按页存储的,如果删除了一个数据页上的所有记录,那么整个数据页就可以被复用了。
注意:数据页的复用和记录的复用是不同的!
记录的复用,只限于符合范围条件的数据。而整个页从 B+ 树里面摘掉以后,可以复用到任何位置。
如果相邻的两个数据页利用率都很小,系统就会把两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。
如果用 delete 命令将整个表的数据删除,所有的数据页都会被标记为可复用。但是磁盘上文件不会变小。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。
实际上,插入数据也会造成这种“空洞”。
比如上图中的 page A 已满,插入一条数据(如 ID 为 550)会造成页分类,page A 和新增的 page B 都会留下“空洞”。
以此类推,更新索引上的值,可以理解为删除一个旧的值,再插入一个新的值,也是会造成空洞的。
因此,重建表可以达到去掉空洞、收缩表空间的目的。
重建表
把表 B 作为临时表,数据从表 A 导入表 B 的操作完成后,用表 B 替换 A,从效果上看,就起到了收缩表 A 空间的作用。
可以使用 alter table A engine=InnoDB 命令来重建表,MySQL 会自动完成转存数据、交换表名、删除旧表的操作。
在整个 DDL 过程中,表 A 中不能有更新。即这个 DDL 不是 Online 的。
MySQL 5.6 版本开始引入的 Online DDL,对该操作流程做了优化。
引入了 Online DDL 之后,重建表的流程:
1、建立一个临时文件,扫描表 A 主键的所有数据页;
2、用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
3、生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中;
4、临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件;
5、用临时文件替换表 A 的数据文件。
总结不同之处在于,由于日志文件记录和重放操作功能的存在,方案在重建表的过程中,允许对表 A 做增删改操作。这也就是 Online DDL 名字的来源。
(关于获取写锁,然后降级成读锁不阻塞更新过程,可参考“锁”章节 Online DDL)
Online DDL 可以考虑在业务低峰期使用,线上服务如果想要更安全的操作的话,建议使用 GitHub 开源的 gh-ost 来操作。
Online和inplace
DDL 中把表 A 中的数据导出来的存放位置叫作 tmp_table。这是一个临时表,在 server 层创建。
Online DDL 中根据表 A 重建出来的数据是放在“tmp_file”里的,这个临时文件是 InnoDB 在内部创建出来的。整个 DDL 过程都在 InnoDB 内部完成。对于 server 层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“inplace”名称的来源。
所以 inplace 的 DDL 受到磁盘空间约束,如果额外的空间不够 tmp_file 使用,则无法执行。
重建表的语句 alter table A engine=InnoDB ,隐含的意思就是:
| |
跟 inplace 对应的就是拷贝表的方式,用法是:
| |
当使用 ALGORITHM=copy 时表示强制拷贝表,对应的流程就是 非 Online 的 DDL。 上面的逻辑看起来 inplace 跟 Online 是一个意思?其实并不是。
比如,给 InnoDB 表的一个字段加全文索引:
| |
该过程是 inplace 的,但是会阻塞 增删改 操作,是非 Online 的。 二者之间的关系概括:
1、DDL 过程如果是 Online 的,那么一定是 inplace 的;
2、反之不一定,inplace 的 DDL 有可能不是 Online 的。
(MySQL 8.0 添加全文索引(FULLTEXT index)和空间索引(SPATIAL index)就属于这种情况。
optimize table、analyze table 和 alter table 区别
1、从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认的 Online DDL
2、analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;
3、optimize table t 等于 recreate+analyze。
表重建后占用空间更大是什么原因?
本身已经没有“空洞”了,DDL 期间,刚好有外部的 DML 正在执行,可能引入一些“空洞”。
更深层次的,在重建表的时候,InnoDB 不会把整张表占满,每个页留了 1/16 给后续的更新用。也就是说,其实重建表之后不是最紧凑的。
临时表
内存临时表
如 MEMORY 内存临时表,当使用 order by rand() 查询语句时会自动生成。或者 create table … engine=memory
磁盘临时表
mp_table_size 这个配置限制了内存临时表的大小,默认值是 16M。如果临时表大小超过了 tmp_table_size,那么内存临时表就会转成磁盘临时表。
磁盘临时表使用的引擎默认是 InnoDB,是由参数 internal_tmp_disk_storage_engine 控制的。
排序过程
当使用磁盘临时表的时候,对应的就是一个没有显式索引的 InnoDB 表的排序过程。
验证过程,将 tmp_table_size 设置成 1024,把 sort_buffer_size 设置成 32768, 把 max_length_for_sort_data 设置成 16。
| |

[OPTIMIZER_TRACE 部分结果]
因为将 max_length_for_sort_data 设置成 16,小于 word 字段的长度定义,所以 sort_mode 里面显示的是 rowid 排序。
rand 字段存放的随机值就 8 个字节,rowid 是 6 个字节,数据总行数是 10000,有 140000 字节,超过了 sort_buffer_size 定义的 32768 字节。但是,number_of_tmp_files 的值是 0。因为这里没有用到临时文件,采用是 MySQL 5.6 版本引入的优先队列排序而不是归并排序(不需要将所有数据都排序)。
优先队列算法可以精确地只得到三个最小值,执行流程如下:
1、对于这 10000 个准备排序的 (rand,rowid),先取前三行,构造成一个堆;
2、取下一个行 (rand’,rowid’),跟当前堆里面最大的 rand 比较,如果 rand’小于 rand,把这个 (rand,rowid) 从堆中去掉,换成 (rand’,rowid’);
3、重复第 2 步,直到第 10000 个 (rand’,rowid’) 完成比较。
临时表特性
1、建表语法是 create temporary table …。
2、临时表只能被创建它的 session 访问,对其他线程不可见。
3、临时表可以与普通表同名。
4、session 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
5、show tables 命令不显示临时表。
由于临时表只能被创建它的 session 访问,所以 session 结束时会自动删除临时表。也正是由于这个特性,临时表就特别适合 join 优化场景。原因主要包括以下两个方面:
1、不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
2、不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。
临时表应用
由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。
临时表数据
frm 文件放在临时文件目录下,文件名的后缀是.frm,前缀是进程id_线程id_序列号,每个线程的线程 id 不同,所以不同线程可以创建同名的临时表。可以使用 select @@tmpdir 命令,来显示实例的临时文件目录。
临时表中数据的存放方式,在不同的 MySQL 版本中有着不同的处理方式:
1、在 5.6 以及之前的版本里,MySQL 会在临时文件目录下创建一个相同前缀、以.ibd 为后缀的文件,用来存放数据文件;
2、从 5.7 版本开始,MySQL 引入了一个临时文件表空间,专门用来存放临时文件的数据。因此不需要再创建 ibd 文件。
MySQL 维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表,每个表都对应一个 table_def_key。
1、一个普通表的 table_def_key 的值是由“库名 + 表名”得到,所以如果要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了。
2、而对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”。
临时表与主从同步
如果当前的 binlog_format=row,那么跟临时表有关的语句,不会记录到 binlog 里。也就是只在 binlog_format=statment/mixed 的时候,binlog 中才会记录临时表的操作。
这种情况下,创建临时表的语句会传到从库执行,因此从库的同步线程就会创建这个临时表。主库在线程退出的时候,会自动删除临时表,但是从库同步线程是持续在运行的。所以这时候就需要在主库上再写一个 DROP TEMPORARY TABLE 传给从库执行。
MySQL 在记录 binlog 的时候,不论是 create table 还是 alter table 语句都是原样记录,甚至于连空格都不变。但是如果执行 drop table t_normal,系统记录 binlog 就会改成了标准的格式:
| |
drop table 命令是可以一次删除多个表的。比如设置 binlog_format=row,如果主库上执行 "drop table t_normal, temp_t"这个命令,那么 binlog 中就只能记录上面改写的语句。因为从库上并没有表 temp_t,将这个命令重写后再传到从库执行,才不会导致从库同步线程停止。
主库上不同的线程创建同名的临时表是没关系的,传到从库执行要把这两个同名的临时表当做两个不同的临时表来处理。MySQL 记录 binlog 时,会把主库执行语句的线程 id 写到 binlog 中。这样从库的应用线程就能够知道执行每个语句的主库线程 id,并利用这个线程 id 来构造临时表的 table_def_key(库名 + 表名 + serverid + thread_id)。
临时表FAQ
MySQL 什么时候使用内部临时表?
1、如果语句执行过程可以一边读数据,一边直接得到结果,不需要额外内存,否则就需要额外的内存,来保存中间结果;
2、join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构;
三者的区别
joinBuffer: 用于在join语句中对驱动表进行暂存用,是无序数组;所以为什么BKA算法在用了JoinBuffer后不能原地排序只好借由MRR算法排序;
sortBuffer: order by 语句时,会将排序列进行暂存。sortBuffer不够的情况下,则是分段排序最后整合;
临时表:一种二维表结构,有主键,有field。所以如果中间数据需要用到二维表特性,那么就需要使用临时表,且内存临时表不够的前提下,会转为使用磁盘临时表
3、如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。
排序,分组,去重等可能会使用内存临时表。
为什么不能用 rename 修改临时表的改名?
在实现上执行 rename table 语句时,要求按照“库名 / 表名.frm”的规则去磁盘找文件,但是临时表在磁盘上的 frm 文件是放在 tmpdir 目录下的,并且文件名的规则是“#sql{进程 id}{线程 id} 序列号.frm”,因此会报“找不到文件名”的错误。
内存表
两个 group by 语句都用了 order by null,使用内存临时表得到的语句结果里,0 在最后一行;而使用磁盘临时表得到的结果里,0 在第一行。这与内存的数据组织结构有关。
内存表数据组织结构
| |
分别执行:Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
mysql> select * from t21;
+----+------+
| id | c |
+----+------+
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| 6 | 6 |
| 7 | 7 |
| 8 | 8 |
| 9 | 9 |
| 0 | 0 |
+----+------+
10 rows in set (0.02 sec)
mysql> select * from t22;
+----+------+
| id | c |
+----+------+
| 0 | 0 |
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| 6 | 6 |
| 7 | 7 |
| 8 | 8 |
| 9 | 9 |
+----+------+
10 rows in set (0.00 sec)
可以看到,内存表 t21 的返回结果里面 0 在最后一行,而 InnoDB 表 t22 的返回结果里 0 在第一行。 InnoDB 表的数据就放在主键索引树上,主键索引是 B+ 树。主键索引上的值是有序存储的。在执行 select * 的时候,就会按照叶子节点从左到右扫描,所以得到的结果里,0 就出现在第一行。

[t22 表的组织结构]
与 InnoDB 引擎不同,Memory 引擎的数据和索引是分开的。下图可以看到,内存表的数据部分以数组的方式单独存放,而主键 id 索引里,存的是每个数据的位置。主键 id 是 hash 索引,索引上的 key 并不是有序的。在内存表 t21 中执行 select * 的时候,走的是全表扫描,也就是顺序扫描这个数组。因此 0 就是最后一个被读到,并放入结果集的数据。

[t21 表的组织结构]
InnoDB 和 Memory 引擎的数据组织方式是不同的:
1、InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式称之为索引组织表(Index Organizied Table)。
2、而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,称之为堆组织表(Heap Organizied Table)。
两个引擎的一些典型不同:
1、InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的;
2、当数据文件有空洞时,InnoDB 表在插入新数据为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值;
3、数据位置发生变化时,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引;
4、InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。
5、InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。
由于内存表的这些特性,每个数据行被删除以后,空出的这个位置都可以被接下来要插入的数据复用。比如,在表 t21 中执行:
| |
就会看到返回结果里,id=10 这一行出现在 id=4 之后,也就是原来 id=5 这行数据的位置。
表 t21 的这个主键索引是哈希索引,因此如果执行范围查询,如 select * from t1 where id<5; ,是用不上索引的,需要走全表扫描。
hash索引和B-Tree索引
内存表也是支持 B-Tree 索引的。在 id 列上创建一个 B-Tree 索引,SQL 语句可以这么写:
| |
表 t1 的数据组织形式就变成了下图,跟 InnoDB 的 b+ 树索引组织形式类似:

Expand/Collapse Code Block
| |
可以看到,执行 select * from t21 where id<5 的时候,优化器会选择 B-Tree 索引,所以返回结果是 0 到 4。 使用 force index 强行使用主键 id 这个索引,id=0 这一行就在结果集的最末尾了。
不建议在生产环境上使用内存表。原因主要包括两个方面:
1、锁粒度问题;
2、数据持久化问题。
内存表的锁
内存表的锁粒度:内存表不支持行锁,只支持表锁。一张表只要有更新,就会堵住其他所有在这张表上的读写操作。
需要注意的是,这里的表锁跟之前介绍过的 MDL 锁不同,但都是表级的锁。
模拟内存表的表级锁
| Session A | Session B | Session C |
|---|---|---|
| update t21 set id=sleep(50) where id=1; | ||
| select * from t21 where id=2; (wait 50s) | ||
| show processlist; |
在这个执行序列里,Session A 的 update 语句要执行 50 秒,在这个语句执行期间 Session B 的查询会进入锁等待状态。Session C 的 show processlist 结果输出如下:
| |
内存表数据持久性
数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。
从库重启,内存表数据会被清空,接收客户端 update 语句后会执行失败,这样就会导致主备同步停止。如果这时候发生主备切换的话,客户端会看到表中数据“丢失”了。
MySQL 在实现上做了这样一件事儿:在数据库重启之后,(主库)往 binlog 里面写入一行 DELETE FROM t1。
由于重启会丢数据,如果一个备库重启,会导致主备同步线程停止;如果主库跟这个备库是双 M 架构,还可能导致主库的内存表数据被删掉。
建议把普通内存表都用 InnoDB 表来代替。有一个场景例外,就是临时表。
内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因:
1、临时表不会被其他线程访问,没有并发性的问题;
2、临时表重启后也是需要删除的,清空数据这个问题不存在;
3、备库的临时表也不会影响主库的用户线程。
扩展延伸
从库重启之后肯定是会导致从库的内存表数据被清空,进而导致主从同步停止。最好的做法是将它修改成 InnoDB 引擎表。如果业务场景暂时不允许修改引擎如何处理从库重启?
假设的是主库暂时不能修改引擎,那么就把从库的内存表引擎先都改成 InnoDB。对于每个内存表,执行
| |
这样就能避免从库重启时数据丢失的问题。 由于主库重启后,会往 binlog 里面写“delete from tbl_name”,这个命令传到从库,从库的同名的表数据也会被清空。因此,就不会出现主从同步停止的问题。
如果由于主库异常重启触发了 HA,这时之前修改过引擎的备库变成了主库。而原来的主库变成了新备库,在新备库上把所有的内存表(这时候表里没数据因为重启了)都改成 InnoDB 表。所以如果不能直接修改主库上的表引擎,可以配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。
union
sort buffer、内存临时表和 join buffer 这三个数据结构都是用来存放语句执行过程中的中间数据,以辅助 SQL 语句的执行的。其中,排序时用到了 sort buffer,在使用 join 语句的时候用到了 join buffer。
union执行流程
| |
第三行的 Extra 字段,表示在对子查询的结果集做 union 的时候,使用了临时表 (Using temporary)。 这条语句的执行流程:
1、创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段。
2、执行第一个子查询,得到 1000 这个值,并存入临时表中。
3、执行第二个子查询:
1)拿到第一行 id=1000,试图插入临时表中。但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行;
2)取到第二行 id=999,插入临时表成功。
4、从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是 1000 和 999。

[union 执行流程]
这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键 id 的唯一性约束,实现了 union 的语义。如果把上面这个语句中的 union 改成 union all 的话,就没有了“去重”的语义。这样执行时就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端。因此也就不需要临时表了。
| |
可以看到,第二行的 Extra 字段显示的是 Using index,表示只使用了覆盖索引,没有用临时表了。
group by执行流程
| |
在 Extra 字段里面,可以看到三个信息: 1、Using index,表示这个语句使用了覆盖索引,选择了索引 a,不需要回表;
2、Using temporary,表示使用了临时表;
3、Using filesort,表示需要排序。
语句的执行流程:
1、创建内存临时表,表里有两个字段 m 和 c,主键是 m;
2、扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
1)如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
2)如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
3、遍历完成后,再根据字段 m 做排序,得到结果集返回给客户端。
如果并不需要对结果进行排序,那可以在 SQL 语句末尾增加 order by null,也就是改成:
| |
这样就跳过了最后排序的阶段,直接从临时表中取数据返回。 内存临时表的大小是有限制的,参数 tmp_table_size 就是控制这个内存大小的,默认是 16M。
如果把内存临时表的大小限制为最大 1024 字节,并把语句改成 id % 100,这样返回结果里有 100 行数据。但是,这时的内存临时表大小不够存下这 100 行数据,也就是说,执行过程中会发现内存临时表大小到达了上限(1024 字节)。
这时候就会把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是 InnoDB。
| |
如果这个表 t1 的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。 扩展:
Q11 和 Q12 都是 order by null,但是 m 的排序不一样,一个是 9-0 另一个是 0-9
因为 Q11 使用的是内存临时表,使用的引擎是 Memory,哈希索引按插入的顺序读取数据;Q12 使用的磁盘临时表,使用的引擎是innodb,innodb是索引组织表,按主键顺序存储数据,所以是按照m字段有序的。
group by索引优化
上面可以看到,不论是使用内存临时表还是磁盘临时表,group by 逻辑都需要构造一个带唯一索引的表,执行代价比较高。如果表的数据量比较大,上面这个 group by 语句执行起来就会很慢。
group by 的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的 id%100 的结果是无序的,所以就需要有一个临时表,来记录并统计结果。如果扫描过程中可以保证出现的数据是有序的,那么计算 group by 的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程:
1、当碰到第一个 1 的时候,已经知道累积了 X 个 0,结果集里的第一行就是 (0,X);
2、当碰到第一个 2 的时候,已经知道累积了 Y 个 1,结果集里的第二行就是 (1,Y);
就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。
InnoDB 的索引,就可以满足这个输入有序的条件!
在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新。你可以用下面的方法创建一个列 z,然后在 z 列上创建一个索引(如果是 MySQL 5.6 及之前的版本,也可以创建普通列和索引,来解决这个问题)
| |
这样索引 z 上的数据就是有序了。上面的 group by 语句就可以改成:
| |
从 Extra 字段可以看到,这个语句的执行不再需要临时表,也不需要排序了。
group by直接排序优化
碰上不适合创建索引的场景就没办法使用索引优化了。
一个 group by 语句中需要放到临时表上的数据量特别大,却还是要按照“先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表”不太好。可以直接走磁盘临时表。
在 group by 语句中加入 SQL_BIG_RESULT 这个提示(hint),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。
MySQL 的优化器看磁盘临时表是 B+ 树存储,存储效率不如数组高,从磁盘空间考虑直接用数组。
| |
流程如下: 1、初始化 sort_buffer,确定放入一个整型字段,记为 m;
2、扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
3、扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
4、排序完成后,就得到了一个有序数组。

[使用 SQL_BIG_RESULT 的执行流程图]
| |
从 Extra 字段可以看到,这个语句的执行没有再使用临时表,而是直接用了排序算法。
group by小结
group by 使用指导原则:
1、如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
2、尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
3、如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
4、如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。
排序
全字段排序
全字段排序流程:
这个语句执行流程如下所示 :
1、初始化 sort_buffer,确定放入查询的所有字段;
2、从二级索引中找到第一个满足条件的主键 id;
3、到主键 id 索引取出整行,取 查询的所有字段 的值,存入 sort_buffer 中;
4、从二级索引中取下一个记录的主键 id;
5、重复步骤 3、4 直到 where 查询条件不满足为止;
6、对 sort_buffer 中的数据按照 order 字段做快速排序;
7、按照排序结果取前 limit 行返回给客户端。
按照 order 字段排序,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。
sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。
可以用下面介绍的方法,来确定一个排序语句是否使用了临时文件。
| |
通过查看 OPTIMIZER_TRACE 的结果来确认的,可以从 number_of_tmp_files 中看到是否使用临时文件。

number_of_tmp_files 表示排序过程中使用的临时文件数。
内存放不下需要排序的数据时,就会用外部排序,一般使用归并排序。这里临时文件数就是归并排序将数据分成的份数,最后再合并成一个文件。
如果 sort_buffer_size 超过了需要排序的数据量大小,number_of_tmp_files 就是 0,表示排序可以直接在内存中完成。
examined_rows 表示参与排序的行数
sort_mode 里面的 packed_additional_fields 的意思是,排序过程对字符串做了“紧凑”处理。即使字段的定义是 varchar(xx),在排序过程中还是要按照实际长度来分配空间。
注意:因为查询 OPTIMIZER_TRACE 表时,需要用到临时表,而 internal_tmp_disk_storage_engine 的默认值是 InnoDB。如果使用的是 InnoDB 引擎的话,把数据从临时表取出来的时候,会让 Innodb_rows_read 的值加 1,可以设置成 MyISAM。
举例:
| |
查询语句:
| |
这里需要在 city 字段加上索引(略)。 explain SQL 查询语句,Extra 这个字段中的“Using filesort”表示的就是需要排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。
rowid排序
如果 MySQL 认为排序的单行长度太大,会采用另外一种 rowid 排序算法(如果没有主键 id,那么会自动生成一个长度为 6 字节的rowid 来作为主键)。
可以通过下列参数设置用于排序的行数据的长度的一个参数。
| |
新的算法会根据行数据的长度计算放入 sort_buffer 的字段,如只要排序的列和主键 id。整体流程如下:
1、初始化 sort_buffer,确定放入两个字段,即 排序字段 和主键 id;
2、从二级索引中找到第一个满足条件的主键 id;
3、到主键 id 索引取出整行,取 条件、id 字段,存入 sort_buffer 中;
4、从二级索引中取下一个记录的主键 id;
5、重复步骤 3、4 直到不满足条件为止;
6、对 sort_buffer 中的数据按照排序字段进行排序;
7、遍历排序结果,取前 limit 行,并按照 id 的值回到原表中取出其它所需字段返回给客户端。
排序对比
MySQL 认为内存太小,会影响排序效率,就会采用 rowid 排序算法,排序过程中一次可以排序更多航,但是需要再回到原表去取数据。
如果认为内存足够大,会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,排序后可以直接从内存中返回查询结果,不用再回到原表中去取数据。
排序的操作成本较高,排序的根本原因在于原来的数据都是无序的,如果要避免排序操作,那么保证原来的数据有序即可。这里自然联想到使用索引,具体来说是联合索引,可以通过 explain 的 Extra 字段来验证。进一步还可以结合覆盖索引,避免回表的操作。
全表扫描
客户端连接
如果库里面的表特别多,连接就会很慢。其实这并不是连接慢也不是服务端慢,而是客户端慢,因为要执行哈希操作构建一个本地的哈希表。
比如有些线上的库,会包含很多表如 6 万个表。会发现每次用客户端连接都会卡在下面这个连接上:
| |
并且终端还提示使用 -A 参数可以关掉自动补全功能,然后客户端就可以快速返回了。 除了加 -A 以外,加 –quick(简写为 -q) 参数,也可以跳过这个阶段。但是需要注意设置这个参数可能会降低服务端的性能。
因为 MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:
1、一种是本地缓存,也就是在本地开一片内存,先把结果存起来。API 开发对应的就是 mysql_store_result 方法。
2、另一种是不缓存,读一个处理一个。API 开发对应的就是 mysql_use_result 方法。
MySQL 客户端默认采用第一种方式,而如果加上–quick 参数,就会使用第二种不缓存的方式。
采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。
server层
对一个 200G 的 InnoDB 表 db1. t,执行一个全表扫描。把扫描结果保存在客户端,会使用类似这样的命令:
| |
服务端查询数据并不需要保存一个完整的结果集。取数据和发数据的流程: 1、获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。
2、重复获取行,直到 net_buffer 写满,调用网络接口发出去。
3、如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
4、如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。
从这个流程可以知道:
1、一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,全表扫描时并不会达到表的大小;
2、socket send buffer 在全表扫描时也不可能达到表的大小(默认定义 /proc/sys/net/core/wmem_default),如果 socket send buffer 被写满,就会暂停读数据的流程。
所以 MySQL 是“边读边发”,就意味着如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,事务的执行时间变长。
InnoDB引擎层
Buffer Pool
介绍 WAL 机制时,分析了 InnoDB 内存的一个作用是保存更新的结果,再配合 redo log,就避免了随机写盘。
内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用是加速查询。
由于有 WAL 机制,当事务提交的时候,磁盘上的数据页是旧的,如果这时马上有一个查询要来读这个数据页,并不需要立即把 redo log 应用到数据页。因为这时候内存数据页的结果是最新的,直接读内存页就可以了。(注意这里跟 change buffer 和 redo log 不矛盾)
InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成可用物理内存的 60%~80%。
内存命中率
Buffer Pool 对查询的加速效果,依赖于内存命中率这个重要指标。
一般一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。查看系统当前 BP 命中率,可以通过 show engine innodb status,“Buffer pool hit rate”字样显示的就是当前的命中率。
LRU算法改进
InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。
但是全表扫描就会把当前的 Buffer Pool 里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说 Buffer Pool 里面主要放的是这个历史数据表的数据。会使内存命令率急剧下降,磁盘压力增加,SQL 语句响应变慢。因此对 LRU 算法进行了改进。
在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。靠近链表头部的 5/8 是 young 区域,靠近链表尾部的 3/8 是 old 区域。
改进后的 LRU 算法逻辑:
1、扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
2、一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;
3、再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。
这个策略最大的收益就是在扫描这个大表的过程中,虽然也用到了 Buffer Pool,但是对 young 区域完全没有影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。
自增id
自增主键
自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,索引更紧凑。
但自增主键不能保证连续递增。
自增值保存策略
实际上,表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。
不同的引擎对于自增值的保存策略不同。
1、MyISAM 引擎的自增值保存在数据文件中。
2、InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:
1)在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。
举例:如果一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时删除 id=10 的行,AUTO_INCREMENT 还是 11。但如果马上重启实例,重启后这个表的 AUTO_INCREMENT 就会变成 10。也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
2)在 MySQL 8.0 版本,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。
自增值修改机制
MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:
1、如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;
2、如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。
根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是 X,当前的自增值是 Y。
1、如果 X<Y,那么这个表的自增值不变;
2、如果 X≥Y,就需要把当前自增值修改为新的自增值。
新的自增值生成算法是:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值。(两个系统参数默认都为1)
自增值修改时机
自增值改成插入的值,是在真正执行插入数据的操作之前。
自增值不连续的原因:
1、唯一键冲突
2、事务回滚
3、批量插入(申请id是上一次的 2 倍)
自增值不能回退的原因
假设有两个并行执行的事务,在申请自增值时,为了避免两个事务申请到相同的自增 id,肯定要加锁然后顺序申请。
1、假设事务 A 申请到了 id=2, 事务 B 申请到 id=3,那么这时候表 t 的自增值是 4,之后继续执行。
2、事务 B 正确提交了,但事务 A 出现了唯一键冲突。
3、如果允许事务 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现:表里面已经有 id=3 的行,而当前的自增 id 值是 2。
4、接下来,继续执行的其他事务就会申请到 id=2,然后再申请到 id=3。这时,就会出现插入语句报错“主键冲突”。
为了解决这个主键冲突,有两种方法:
1、每次申请 id 之前,先判断表里面是否已经存在这个 id。如果存在,就跳过这个 id。但是,这个方法的成本很高。因为,本来申请 id 是一个很快的操作,现在还要再去主键索引树上判断 id 是否存在。
2、把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。
这两个方法都会导致性能问题。因此 InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。
自增锁的优化
自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。(在 MySQL 5.1 版本之前,并不是这样的)
在 MySQL 5.0 版本,自增锁的范围是语句级别。如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。这样设计会影响并发度。
MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1。
1、参数值为 0 时,表示采用之前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;
2、参数值为 1 时:普通 insert 语句,自增锁在申请之后就马上释放;类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
2、参数值为 2 时,所有的申请自增主键的动作都是申请后就释放锁。
默认设置下 insert … select 要使用语句级锁 和 默认值不为 2 都是为了数据的一致性。
举例:
| Session A | Session B |
|---|---|
| insert into t31 values(null, 1, 1); insert into t31 values(null, 2, 2); insert into t31 values(null, 3, 3); insert into t31 values(null, 4, 4); | |
| create table t32 like t31; | |
| insert into t32 values(null, 5, 5); | insert into t32(c,d) select c,d from t31; |
如果 Session B 是申请了自增值以后马上就释放自增锁,那么可能出现:
1、Session B 先插入了两个记录,(1,1,1)、(2,2,2);
2、然后 Session A 来申请自增 id 得到 id=3,往 t32 插入了(3,5,5);
3、之后 Session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。
如果 binlog_format=statement,由于两个 session 是同时执行插入数据命令的,所以 binlog 里面对表 t32 的更新日志只有两种情况:要么先记 Session A 要么先记 Session B 。
但不论是哪一种,binlog 去从库执行或者用来恢复临时实例,从库和临时实例里面,Session B 语句执行出来生成的结果里面,id 都是连续的。这时,这个库就发生了数据不一致。
出现这个问题是因为原库 Session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用 statement 格式的 binlog 来串行执行,是执行不出来的。
要解决这个问题有两种思路:
1、让原库的批量插入数据语句,固定生成连续的 id 值。自增锁直到语句执行结束才释放就是为了这个目的。
2、在 binlog 里面把插入数据的操作都如实记录进来,到从库执行的时候,不再依赖于自增主键去生成。其实就是 innodb_autoinc_lock_mode 设置为 2,同时 binlog_format 设置为 row。
生产上尤其是有 insert … select 这种批量插入数据(insert … select、replace … select 和 load data 语句)的场景时,从并发插入数据性能的角度考虑,建议这样设置:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row. 这样既能提升并发性又不会出现数据一致性问题。
在普通的 insert 语句里面包含多个 value 值的情况下,即使 innodb_autoinc_lock_mode 设置为 1,也不会等语句执行完成才释放锁。因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,一次性申请完成后锁就可以释放了。所以只有针对不知道预先需要申请多少个 id 的场景需要注意。
批量插入申请id策略
对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:
1、语句执行过程中,第一次申请自增 id,会分配 1 个;
2、1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
3、2 个用完以后,这个语句第三次申请自增 id,会分配 4 个;
4、依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。
举例:
| |
insert…select,实际上往表 t32 中插入了 4 行数据。但是,这四行数据是分三次申请的自增 id,第一次申请到了 id=1,第二次被分配了 id=2 和 id=3, 第三次被分配到 id=4 到 id=7。由于这条语句实际只用上了 4 个 id,所以 id=5 到 id=7 就被浪费掉了。之后,再执行 insert into t32 values(null, 5,5),实际上插入的数据就是(8,5,5)。
这是主键 id 出现自增 id 不连续的第三种原因。
自增主键主从同步
binlog_format=statement下,即使两个 INSERT 语句在主备库的执行顺序不同,自增主键字段的值也不会不一致。因为会通过 SET INSERT_ID 设置主键:
| |
自增id上限
无符号整型 (unsigned int) 是 4 个字节,上限就是 2^32-1。
表定义自增值id
| |
2^32-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表时需要考察表是否有可能达到这个上限,如果有可能,就应该创建成 8 个字节的 bigint unsigned。
InnoDB自增row_id
如果创建的 InnoDB 表没有指定主键,那么 InnoDB 会给你创建一个不可见的,长度为 6 个字节的 row_id。InnoDB 维护了一个全局的 dict_sys.row_id 值,所有无主键的 InnoDB 表,每插入一行数据,都将当前的 dict_sys.row_id 值作为要插入数据的 row_id,然后把 dict_sys.row_id 的值加 1。
实际上,在代码实现时 row_id 是一个长度为 8 字节的无符号长整型 (bigint unsigned)。但是,InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,这样写到数据表中时只放了最后 6 个字节,所以 row_id 能写到数据表中的值,就有两个特征:
1、row_id 写入表中的值范围,是从 0 到 2^48-1;
2、当 dict_sys.row_id=2^48时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0。
验证(使用 gdb 修改系统的自增 row_id 来实现):
| |

图中可以看到,a=1 的数据被覆盖了。
从这个角度看,我们还是应该在 InnoDB 表中主动创建自增主键。因为,表自增 id 到达上限后,再插入数据时报主键冲突错误,是更能被接受的。
毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。
Xid
redo log 和 binlog 相配合的时候,提到了它们有一个共同的字段叫作 Xid。它在 MySQL 中是用来对应事务的。
MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1。如果当前语句是这个事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid。
而 global_query_id 是一个纯内存变量,重启之后就清零了。所以在同一个数据库实例中,不同事务的 Xid 也是有可能相同的。
但是 MySQL 重启之后会重新生成新的 binlog 文件,这就保证了,同一个 binlog 文件里,Xid 一定是惟一的。
如果 global_query_id 达到上限后,就会继续从 0 开始计数。从理论上讲,还是就会出现同一个 binlog 里面出现相同 Xid 的场景。2^64-1 太大了,这个可能性仅存在理论上。
innodb trx_id
Xid 是由 server 层维护的。InnoDB 内部使用 Xid 是为了能够在 InnoDB 事务和 server 之间做关联。trx_id 就是MVCC的版本号,是InnoDB内部自己维护的,每次事务开启时都会向InnoDB事务系统申请,是严格顺序递增的。
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。
InnoDB 数据可见性的核心思想是:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。
对于正在执行的事务,你可以从 information_schema.innodb_trx 表中看到事务的 trx_id。如:
| |
trx_id 实验的时候发现不止加 1,原因如下:
1、update 和 delete 语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到 purge 队列里等待后续物理删除,这个操作也会把 max_trx_id+1, 因此在一个事务中至少加 2;
2、InnoDB 的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id 值并不是按照加 1 递增的。
对于只读事务(select 后面加上 for update 不是只读事务),InnoDB 并不会分配 trx_id。会话没有执行到更新语句也是只读事务,等执行到更新语句才不是只读事务,才开始分配 trx_id。
只读事务分配的 trx_id 仅用作展示,值看起来很大。这个数字是每次查询的时候由系统临时计算出来的。算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2^48。可以保证如下:
1、为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的。
2、如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的 trx_id 就是不同的。
在显示值里面加上 2^48,目的是要保证只读事务显示的 trx_id 值比较大,正常情况下就会区别于读写事务的 id。但是,trx_id 跟 row_id 的逻辑类似,定义长度也是 8 个字节。因此,在理论上还是可能出现一个读写事务与一个只读事务显示的 trx_id 相同的情况。不过这个概率很低,并且也没有什么实质危害,可以忽略。
只读事务不分配 trx_id 的好处:
1、可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,是不影响数据的可见性判断的。所以在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id。
2、可以减少 trx_id 的申请次数。在 InnoDB 里,即使只是执行一个普通的 select 语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请 trx_id,就大大减少了并发事务申请 trx_id 的锁冲突。
max_trx_id 会持久化存储,重启也不会重置为 0,理论上只要一个 MySQL 服务跑得足够久,就可能出现 max_trx_id 达到 248-1 的上限,然后从 0 开始的情况。当达到这个状态后,MySQL 就会持续出现一个脏读的 bug。
| |
| Session A | Session B | |
|---|---|---|
| T1 | begin; select * from tem; (1,1) | |
| update tem set c=2 where id=1; begin; update tem set c=3 where id=1; | ||
| select * from tem; (1,3) // 脏读 |
由于已经把系统的 max_trx_id 设置成了 2^48-1,所以在 Session A 启动的事务 TA 的低水位就是 2^48-1。
T2 时刻,Session B 执行第一条 update 语句的事务 id 就是 2^48-1,而第二条 update 语句的事务 id 就是 0,即 trx_id 就是 0。
T3 时刻,Session A 执行 select 语句时,判断可见性发现 c=3 的 trx_id 小于事务 TA 的低水位,因此认为这个数据可见。
这就导致了脏读。且MySQL 重启时 max_trx_id 也不会清 0,也就是说重启 MySQL,这个 bug 仍然存在。这个 bug 是只要 MySQL 实例服务时间够长,就会必然出现的。
thread_id
thread_id 就是系统保存了一个全局变量 thread_id_counter,每新建一个连接就将 thread_id_counter 赋值给这个新连接的线程变量。
show processlist 里的第一列就是 thread_id。
thread_id_counter 定义的大小是 4 个字节,达到 2^32-1 后就会重置为 0,然后继续增加。不会在 show processlist 里看到两个相同的 thread_id。因为 MySQL 设计了一个唯一数组的逻辑,给新线程分配 thread_id 的时候,逻辑代码会判断:
| |
自增id上限小结
每种自增 id 有各自的应用场景,在达到上限后的表现也不同:
1、表的自增 id 达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误。
2、row_id 达到上限后,则会归 0 再重新递增,如果出现相同的 row_id,后写的数据会覆盖之前的数据。
3、Xid 只需要不在同一个 binlog 文件中出现重复值即可。虽然理论上会出现重复值,但是概率极小,可以忽略不计。
4、InnoDB 的 max_trx_id 递增值每次 MySQL 重启都会被保存起来,上文中提到的脏读的例子就是一个必现的 bug,好在还有很充裕的时间去解决。
5、thread_id 是使用中最常见的,而且也是处理得最好的一个自增 id 逻辑。
分区表
| |
这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件。即: 1、对于引擎层来说,这是 4 个表;
2、对于 Server 层来说,这是 1 个表。
(略,待补充)
最佳实践
更新相同数据
表:
| |
执行更新语句
| |
可能发生以下三种情况: 1、更新都是先读后写,MySQL 读出数据发现值与原来相同,不更新直接返回;
2、MySQL 调用了 InnoDB 引擎提供的修改接口,但是引擎发现值与原来相同,不更新直接返回;
3、InnoDB 执行了修改接口,该解锁的加锁,该更新的更新。
问题:实际情况是哪种?如何构造实验验证?
验证情况 1
| Session A | Session B |
|---|---|
| begin; update t3 set a=2 where id=1; | |
| update t3 set a=2 where id=1; (blocked) |
Session B 的 update 语句被 blocked 了,加锁这个动作是 InnoDB 才能做的,所以排除情况 1。
验证情况 2
使用可见性实验确认
| Session A | Session B |
|---|---|
| begin; select * from t3 where id=1; # 返回 (1,2) | |
| update t3 set a=3 where id=1; | |
| update t3 set a=3 where id=1; Query OK, 0 rows affected (0.00 sec) Rows matched: 1 Changed: 0 Warnings: 0 | |
| select * from t3 where id=1; # 返回 (1,3) |
(注意:这里 set a=3 不会触发“当前读”)
session A 的第二个 select 语句是一致性读(快照读),它是不能看见 session B 的更新的。
现在它返回的是 (1,3),表示它看见了某个新的版本,这个版本只能是 session A 自己的 update 语句做更新的时候生成。
所以应该是情况 3。
为什么 MySQL 更新前不判断值是否相同?
其实 MySQL 确认了。只是这个语句中,MySQL 认为读出来的值只有 id=1 是确定的,而要写的是 a=3,只从这两个信息看不出来“不需要修改”。
验证:
| Session A | Session B |
|---|---|
| begin; select * from t3 where id=1; # 返回 (1,2) | |
| update t3 set a=3 where id=1; | |
| update t3 set a=3 where id=1 and a=3; Query OK, 0 rows affected (0.01 sec) Rows matched: 1 Changed: 0 Warnings: 0 | |
| select * from t3 where id=1; # 返回 (1,2) |
注意:上面的验证结果都是在 binlog_format=statement 格式下进行的。
如果是 binlog_format=row 并且 binlog_row_image=FULL 的时候,由于 MySQL 需要在 binlog 里面记录所有的字段,所以在读数据的时候就会把所有数据都读出来了。
根据上面说的规则,“既然读了数据,就会判断”, 因此在这时候,select * from t where id=1,结果就是“返回 (1,2)”。
同理,如果是 binlog_row_image=NOBLOB, 会读出除 blob 外的所有字段,在我们这个例子里,结果还是“返回 (1,2)”。
MySQL 5.6 版本引入的,对应的代码读字段逻辑:

如果表中有 timestamp 字段而且设置了自动更新的话,那么更新“别的字段”的时候,MySQL 会读入所有涉及的字段,这样通过判断,就会发现不需要修改。
in使用索引
例:
| |
方法1:
| |
方法2:
| |
rand排序
表结构:
Expand/Collapse Code Block
| |
随机读取 3 个单词:
| |
分析:
explain 查看 Extra 字段显示 Using temporary,表示需要使用临时表;Using filesort 表示需要执行排序操作。
对于 InnoDB 表来说,执行全字段排序会减少磁盘访问,会被优先选择。
但是这里是 临时内存表,回表过程只是简单根据数据行的位置直接访问内存得到数据,不会增加访问磁盘次数。所以优化器会优先考虑用于排序的行越小越好,因此这时会选择 rowid 排序。
上述随机读取单词 SQL 执行流程:
1、创建一个临时表。临时表使用的是 memory 引擎,表里有主键 id 和 double 类型的 rand。(表没有索引)
2、按主键顺序取出所有的 word 字段,对于每一个 word 值,调用 rand() 生成一个大于 0 小于 1 的随机小数,并把随机小数和 word 分别存入临时表的字段中,到此扫描行数是 10000。
3、在内存临时表中,准备按照 rand() 字段排序。
4、初始化 sort_buffer。一个主键 id 另外一个 rand 字段。
5、从内存临时表中一行一行取出 rand 值和位置信息(InnoDB 表是主键 id,MEMORY 引擎不是索引组织表,可以理解 rowid 就是数组的下标),分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加了 10000,变成了 20000。
6、在 sort_buffer 中根据 rand 值进行排序。注意:这里没有涉及到表操作,不会增加扫描行数。
7、排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出 word 值返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了 20003。
可以通过慢日志(slow log)验证上述分析得到的扫描行数。
| |
Rows_examined:20003 就表示这个语句执行过程中扫描了 20003 行。
随机排序
同上,如果只随机选择 1 个 word 值。
随机算法1
1、取得这个表的主键 id 的最大值 M 和最小值 N;
2、用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
3、取不小于 X 的第一个 ID 的行。
执行语句:
| |
该方法效率很高,取 max(id) 和 min(id) 都是不需要扫描索引的,而第三步的 select 也可以用索引快速定位,可以认为就只扫描了 3 行。 但它不是真正的随机,因为 id 可能不是连续的,选择不同行的概率不一样。
随机算法2
1、取得整个表的行数,并记为 C。
2、取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
3、再用 limit Y,1 取得一行。
| |
(limit 后面的参数不能直接跟变量,代码中使用了 prepare+execute 的方法) 总结,这种随机算法比 rand 排序效率要高很多。
进阶:如果需要取 3 个值
随机算法3
1、取得整个表的行数,记为 C;
2、根据相同的随机方法得到 Y1、Y2、Y3;
3、再执行三个 limit Y, 1 语句得到三行数据。
| |
随机算法4
随机算法 3 的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),对其进一步优化,减少扫描行数。
取 Y1、Y2 和 Y3 里面最大的一个数,记为 M,最小的一个数记为 N,然后执行下面这条 SQL 语句:
| |
再加上取整个表总行数的 C 行,扫描行数总共只需要 C+M+1 行。
SQL长时间不返回
表结构与数据:
Expand/Collapse Code Block
| |
等MDL锁
查询语句:
| |
一般这种情况,大概率是表 t4 被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,查看当前语句处于什么状态。 show processlist 可以看到 Waiting for table metadata lock,这个状态表示,有一个线程正在表 t4 上请求或者持有 MDL 写锁,把 select 语句堵住了。
| |
MySQL 5.7 复现:
| Session A | Session B |
|---|---|
| lock table t4 write; | |
| select * from t4 where id=1; |
由于在 show processlist 的结果里面,session A 的 Command 列是“Sleep”,导致查找不方便。不过有了 performance_schema 和 sys 系统库之后比较方便。(MySQL 启动时需要设置 performance_schema=on,相比于设置为 off 会有 10% 左右的性能损失)
| |
等flush
查询语句:
(这里的 id 是上面 show processlist 结果的 ID)
| |
通过 State 字段如“Waiting for table flush”,表示有一个线程正要对表 t 做 flush 操作。MySQL 里面对表做 flush 操作的用法,一般有以下两个:
| |
出现 Waiting for table flush 状态的可能情况是:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了我们的 select 语句。 复现:
| Session A | Session B | Session C |
|---|---|---|
| select sleep(1) from t4; | ||
| flush tables t4; | ||
| select * from t4 where id=1; |
session A 中,每行都调用一次 sleep(1),这样语句默认要执行 10 万秒,在这期间表 t4 一直是被 session A“打开”着。然后,session B 的 flush tables t4 命令再要去关闭表 t4,就需要等 session A 的查询结束。这样,session C 要再次查询的话,就会被 flush 命令堵住了。
等行锁
共享锁 (lock in share mode)、排他锁 (for update)
查询语句:
| |
由于访问 id=1 记录时要加读锁,如果此时已经有一个事务在这行记录上持有一个写锁, select 语句就会被堵住。 排查方法:
如果是 MySQL 5.7 版本,可以通过 sys.innodb_lock_waits 表查到。
Expand/Collapse Code Block
| |
注意:如果上面结果是 Empty set, 3 warnings (0.00 sec),那么可能是上面查询结果超时了被结束掉了。 可以看到信息很全,20 号线程是造成堵塞的罪魁祸首,并给出了方法就是 KILL QUERY 4 或 KILL 4。“KILL QUERY 4”表示停止 4 号线程当前正在执行的语句,但该方法是没有用的。因为占有行锁的是 update 语句,这个语句已经是之前执行完成了的,现在执行 KILL QUERY,无法让这个事务去掉 id=1 上的行锁。
实际 KILL 4 才有效,即直接断开这个连接。这里隐含的逻辑是:连接被断开的时候,会自动回滚连接里面正在执行的线程,也就释放了 id=1 上的行锁。
SQL查询慢
复用上面的表 t4
没有索引查询慢
查询语句:
| |
由于字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要扫描 5 万行。 可以通过查询慢日志验证
(注意,为了把所有语句记录到 slow log 里,先执行了 set long_query_time=0,将慢查询日志的时间阈值设置为 0。)
不加读锁更慢
| Session A | Session B |
|---|---|
| start transaction with consistent snapshot; | |
| update t4 set c=c+1 where id=1; //执行一万次 | |
| select * from t4 where id=1; | |
| select * from t4 where id=1 lock in share mode; |
带 lock in share mode 的 SQL 语句是当前读,会直接读到 1000001 这个结果,所以速度很快;
而 select * from t where id=1 是一致性读,因此需要从 1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。
扩展
如果是下面的语句:
| |
那么如何加锁?什么时候释放锁? RC 隔离级别下,对非索引字段更新,有个锁全表记录的过程,不符合条件的会及时释放行锁,不必等事务结束时释放;而直接用索引列更新,只会锁索引查找值和行。update产生的 X 锁在不释放的情况下,DELETE语句无法执行,但是 UPDATE 语句能更新不符合之前X锁的记录。
RR 隔离级别下,为保证 binlog 记录顺序,非索引更新会锁住全表记录,且事务结束前不会对不符合条件记录有逐步释放的过程。DELETE 和 UPDATE 语句都不能执行
客户端响应慢
如果客户端由于压力过大,迟迟不能接收数据,会对服务端造成什么严重的影响?
这个问题的核心是,造成了“长事务”。结合锁、MVCC 的知识点。
1、如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住;
2、当然读的事务也有问题,就是会导致 undo log 不能被回收,导致回滚段空间膨胀。
误删数据
误删数据一般分为以下几类:
1、使用 delete 语句误删数据行;
2、使用 drop table 或者 truncate table 语句误删数据表;
3、使用 drop database 语句误删数据库;
4、使用 rm 命令误删整个 MySQL 实例。
误删行
如果是使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。
Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而使用这个方案的前提是需要确保 binlog_format=row 和 binlog_row_image=FULL。
具体恢复数据时,对单个事务做如下处理:
1、对于 insert 语句,对应的 binlog event 类型是 Write_rows event,把它改成 Delete_rows event 即可;
2、同理,对于 delete 语句,也是将 Delete_rows event 改为 Write_rows event;
3、而如果是 Update_rows 的话,binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。
如果误操作不是一个而是多个,比如下面三个事务:
| |
用 Flashback 工具解析 binlog 后,写回主库的命令是:
| |
也就是说,如果误删数据涉及到多个事务,需要将事务的顺序调过来再执行。 需要说明的是,不建议直接在主库上执行这些操作。
恢复数据比较安全的做法是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。
因为一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了,导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。如果这时单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏。
误删数据的事前预防:
1、把 sql_safe_updates 参数设置为 on。如果忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话执行就会报错。
2、代码上线前,必须经过 SQL 审计。
delete 全表很慢,需要生成回滚日志、写 redo、写 binlog。所以,从性能角度考虑,应该优先考虑使用 truncate table 或者 drop table 命令。
使用 truncate /drop table 和 drop database 命令删除数据,没办法通过 Flashback 来恢复。
因为即使配置了 binlog_format=row,执行命令记录的 binlog 还是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息无法恢复数据。
误删库表
要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。
这两个条件都具备的情况下,假如有人中午 12 点误删了一个库,恢复数据的流程如下:
1、取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
2、用备份恢复出一个临时库;
3、从日志备份里面,取出凌晨 0 点之后的日志;
4、把这些日志,除了误删除数据的语句外,全部应用到临时库。
这个过程需要注意:
1、如果这个临时库上有多个数据库,为了加速数据恢复,可以在使用 mysqlbinlog 命令时,加上一个–database 参数,用来指定误删表所在的库。这样就避免在恢复数据时还要应用其他库日志的情况。
2、在应用日志的时候,需要跳过 12 点误操作的那个语句的 binlog:
1)如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用–stop-position 参数执行到误操作之前的日志,然后再用–start-position 从误操作之后的日志继续执行;
2)如果实例使用了 GTID 模式更加方便。假设误操作命令的 GTID 是 gtid1,那么只需要执行 set gtid_next=gtid1;begin;commit; 先把这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时候,就会自动跳过误操作的语句。
使用 mysqlbinlog 方法恢复数据可能还不够快,主要原因有两个:
1、如果是误删表,最好就是只恢复出这张表,即只重放这张表的操作,但是 mysqlbinlog 工具并不能指定只解析一个表的日志;
2、用 mysqlbinlog 解析出日志应用,应用日志的过程就只能是单线程。并行复制的方法,在这里都用不上。
加速备份恢复的办法
1、从最近一次全量备份恢复出来一个临时库
2、将误删表的gtid加入临时库
3、将临时库设为线上备库的从库
4、临时库就可以并行复制备库的 binlog
延迟复制从库
有非常核心的业务,不允许太长的恢复时间,可以考虑搭建延迟复制的备库。这个功能是 MySQL 5.6 版本引入的。
延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。比如把 N 设置为 3600,这就代表了如果主库上有数据被误删了,并且在 1 小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。
预防误删库表
1、账号分离。目的避免写错命令。
比如:只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。
show grants 命令查看账户的权限
2、制定操作规范。目的避免写错要删除的表名。
在删除数据表之前,必须先对表做改名操作。观察一段时间,确保对业务无影响以后再删除表。改表名时要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且管理系删除表时,只能删除固定后缀的表。
rm删除数据
对于一个有高可用机制的 MySQL 集群,最不怕 rm 删除数据。只要不是恶意地删除整个集群,而只是删掉了其中某一个节点的数据,HA 系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。要做的就是在这个节点上把数据恢复回来,再接入整个集群。
现在不止 DBA 有自动化系统,SA(系统管理员)也有自动化系统,所以也许一个批量下线机器的操作,会让整个 MySQL 集群的所有节点都下线。应对这种情况,建议只能是说尽量备份跨机房,或者最好是跨城市保存。
复制表
mysqldump复制
把结果输出到临时文件:
| |
主要参数含义如下: 1、–single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
2、–add-locks 设置为 0,表示在输出的文件结果里,不增加" LOCK TABLES t WRITE;" ;
3、–no-create-info 的意思是,不需要导出表结构;
4、–set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
5、–result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。
将这些 INSERT 语句放到 db2 库里去执行:
| |
source 不是一条 SQL 语句,而是一个客户端命令。mysql 客户端执行这个命令的流程: 1、打开文件,默认以分号为结尾读取一条条的 SQL 语句;
2、将 SQL 语句发送到服务端执行。
导出csv文件
| |
注意如下几点。 1、这条语句会将结果保存在服务端。如果执行命令的客户端和 MySQL 服务端不在同一个机器上,客户端机器的临时目录下是不会生成 t.csv 文件的。
2、into outfile 指定了文件的生成位置(/server_tmp/),这个位置必须受参数 secure_file_priv 的限制。参数 secure_file_priv 的可选值和作用分别是:
1)如果设置为 empty,表示不限制文件生成的位置,这是不安全的设置;
2)如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
3)如果设置为 NULL,就表示禁止在这个 MySQL 实例上执行 select … into outfile 操作。
3、这条命令不会覆盖文件,因此需要确保 /server_tmp/t.csv 这个文件不存在,否则执行语句时就会因为有同名文件的存在而报错。
4、这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样可以跟字段之间、数据行之间的分隔符区分开。
将数据导入到目标表 db2.t 中:
| |
这条语句的执行流程: 1、打开文件 /server_tmp/t.csv,以制表符 ( ) 作为字段间的分隔符,以换行符( )作为记录之间的分隔符,进行数据读取;
2、启动事务。
3、判断每一行的字段数与表 db2.t 是否相同:
1)若不相同,则直接报错,事务回滚;
2)若相同,则构造成一行,调用 InnoDB 引擎接口,写入到表中。
4、重复步骤 3,直到 /server_tmp/t.csv 整个文件读入完成,提交事务。
(binlog_format=statement)load 语句记录到 binlog 在从库重放
由于 /server_tmp/t.csv 文件只保存在主库所在的主机上,如果只是把这条语句原文写到 binlog 中,在备库执行的时候,备库的本地机器上没有这个文件,就会导致主备同步停止。所以流程应该如下:
1、主库执行完成后,将 /server_tmp/t.csv 文件的内容直接写到 binlog 文件中
2、往 binlog 文件中写入语句 load data local infile ‘/tmp/SQL_LOAD_MB-1-0’ INTO TABLE db2.t。
3、把这个 binlog 日志传到备库。
4、备库的 apply 线程在执行这个事务日志时:
1)先将 binlog 中 t.csv 文件的内容读出来,写入到本地临时目录 /tmp/SQL_LOAD_MB-1-0 中;
2)再执行 load data 语句,往备库的 db2.t 表中插入跟主库相同的数据。
local 的意思是“将执行这条命令的客户端所在机器的本地文件 /tmp/SQL_LOAD_MB-1-0 的内容,加载到目标表 db2.t 中”。有两种用法:
1、不加“local”,是读取服务端的文件,这个文件必须在 secure_file_priv 指定的目录或子目录下;
2、加上“local”,读取的是客户端的文件,只要 mysql 客户端有访问这个文件的权限即可。这时候,MySQL 客户端会先把本地文件传给服务端,然后执行上述的 load data 流程。
select …into outfile 方法不会生成表结构文件, 所以我们导数据时还需要单独的命令得到表结构定义。mysqldump 提供了一个–tab 参数,可以同时导出表结构定义文件和 csv 数据文件。这条命令的使用方法如下:
| |
这条命令会在 $secure_file_priv 定义的目录下,创建一个 t.sql 文件保存建表语句,同时创建一个 t.txt 文件保存 CSV 数据。
物理拷贝
在 MySQL 5.6 版本引入了可传输表空间(transportable tablespace) 的方法,可以通过导出 + 导入表空间的方式,实现物理拷贝表的功能。
假设现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:
1、执行 create table r like t,创建一个相同表结构的空表;
2、执行 alter table r discard tablespace,这时 r.ibd 文件会被删除;
3、执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
4、在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
5、执行 unlock tables,这时候 t.cfg 文件会被删除;
6、执行 alter table r import tablespace,将 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。
9.2.5 - 04.索引
简介
索引的出现其实就是为了提高数据查询的效率,就像书的目录一样
索引常见模型
实现索引的方式有很多种,可以用户提高读写效率的数据结构很多,三种常见、比较简单的数据结构分别是:哈希表、有序数组和搜索树。
跳表、LSM 树等数据结构也被用于引擎设计中。
哈希表
优点:
增加新的索引速度很快
缺点:
不是有序的,做区间查询慢
适用于只有等值查询的场景
有序数组
优点:
查询效率高
缺点:
更新数据效率低
只适用于静态存储引擎
搜索树
二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。
一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。
为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用“N 叉”树。这里,“N 叉”树中的“N”取决于数据块的大小。
以 InnoDB 的一个整数字段索引为例,这个 N 差不多是 1200。这棵树高是 4 的时候,就可以存 1200 的 3 次方个值,这已经 17 亿了。考虑到树根的数据块总是在内存中的,一个 10 亿行的表上一个整数字段的索引,查找一个值最多只需要访问 3 次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。
N 叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。
InnoDB索引模型
在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为前面我们提到的,InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。
每一个索引在 InnoDB 里面对应一棵 B+ 树。
索引类型
根据叶子节点的内容,索引类型分为主键索引和非主键索引。
主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。
非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。
基于主键索引和普通索引的查询有什么区别?
- 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
- 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
索引维护
非自增主键
如果新增一个中间的主键 ID 记录,需要逻辑上挪动后面的数据空出位置。更糟的情况是 当前所在的页 已经满了,根据 B+ 树的算法需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。会影响性能和数据页的利用率。原本放在一个页的数据,需要分到两个页中,整体空间利用率降低大约 50%。(而自增主键防止页分裂,逻辑删除而非物理删除)
有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。
自增主键
自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT 。
插入新记录的时候可以不指定 ID 的值,系统会获取当前 ID 最大值加 1 作为下一条记录的 ID 值。
也就是说,自增主键的插入数据模式,正符合了前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
而由业务逻辑的字段做主键,则往往不容易保证有序插入,写数据成本相对较高。
身份证做主键还是自增主键?
由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。
显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
结论:从性能和存储空间方面考量,自增主键往往是合理的选择。
1、业务字段不一定是递增的,有可能会造成主键索引的页分裂,导致性能不稳定;
2、二级索引存储的值是主键,如果使用业务字段占用大小不好控制,业务字段过长可能会导致二级索引占用空间过大,利用率不高。
适合业务字段做主键的场景
1、只有一个索引
2、该索引必须是唯一索引
(这不就是 KV 场景么)
由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。这时候就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。
非聚集组合索引的一种形式,它包括在查询里的 Select、Join 和 Where 子句用到的所有列(即建立索引的字段正好是覆盖查询语句 [select子句] 与查询条件 [Where子句] 中所涉及的字段,也即,索引包含了查询正在查找的所有数据)。
覆盖索引
不是所有类型的索引都可以成为覆盖索引。覆盖索引必须要存储索引的列,而哈希索引、空间索引和全文索引等都不存储索引列的值,所以 MySQL 只能使用 B-Tree 索引做覆盖索引
当发起一个被索引覆盖的查询(也叫作索引覆盖查询)时,在 EXPLAIN 的Extra 列可以看到“Using index”的信息
Using where:表示优化器需要通过索引回表查询数据;
Using index:表示直接访问索引就足够获取到所需要的数据,不需要通过索引回表;
Using index condition:在5.6版本后加入的新特性(Index Condition Pushdown);
Using index condition 会先条件过滤索引,过滤完索引后找到所有符合索引条件的数据行,随后用 WHERE 子句中的其他条件去过滤这些数据行;
最左前缀原则
索引项是按照索引定义里面出现的字段顺序排序的。
只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。
建立联合索引时,如何安排索引内的字段顺序?
第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。
索引下推
索引下推定义
索引下推(index condition pushdown )简称 ICP,在 Mysql 5.6 的版本上推出,用于优化查询。
在不使用 ICP 的情况下,在使用非主键索引(又叫普通索引或者二级索引)进行查询时,存储引擎通过索引检索到数据,然后返回给 MySQL 服务器,服务器然后判断数据是否符合条件 。
在使用 ICP 的情况下,如果存在某些被索引的列的判断条件时,MySQL 服务器将这一部分判断条件传递给存储引擎,然后由存储引擎通过判断索引是否符合 MySQL 服务器传递的条件,只有当索引符合条件时才会将数据检索出来返回给 MySQL 服务器。
索引条件下推优化可以减少存储引擎查询基础表(回表)的次数,也可以减少 MySQL 服务器从存储引擎接收数据的次数。
索引下推案例
如建立了一个联合索引(name, age),要同时查询 name like '张%' and age=20 的条件。
如果是根据最左前缀索引规则(没有索引下推),只用到了 name 的索引。需要回表到主键索引找出数据行,然后再比对 age 过滤。
使用了索引下推,就会在(name, age)联合索引内部判断 age 是否等于 20,减少回表的次数。
索引查询过程
1、对于普通索引来说,查找到满足条件的第一个记录后,需要查找下一个记录,直到碰到第一个不满足索引条件的记录。
2、对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。
这点不同带来的影响微乎其微。因为 InnoDB 是按数据页为单位读写的,当读一条记录的时候,并不是将这个记录从磁盘读出来,而是以页为单位,将其整体读入内存。在 InnoDB 中,每个数据页的大小默认是 16KB。
对于下一条记录在同一个数据页时,多做的那一次“查找和判断下一条记录”操作,只需要一次指针寻找和一次计算。对于整型字段,一个数据页可以存放近千个 key,所以在同一个数据页的概率较大。
如果下一条记录在下一个数据页,则必须读取下一个数据页。
综合计算平均性能差异时,扔可以认为该操作成本可以忽略不计。
优化器
优化器选择索引的依据是综合考虑 扫描行数、是否使用临时表、是否排序等因素。
扫描行数
MySQL 在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。(explain 中的 rows 字段)
这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。基数越大,索引的区分度越好。
可以使用 show index from tablename 看到索引的基数
| |
三个索引的基数值不同,结果并不一定准确。 如何得到索引的基数?
一行行统计代价太高,一般选择“采样统计”。
采样的时候 InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。
而数据表会持续更新,索引统计信息也不会固定不变。所以当变更的数据行数超过 1/M 时,会自动触发重新做一次索引统计。
MySQL 中,有两种存储索引统计的方式,可以通过设置参数 innodb_stats_persistent 的值来选择:
1、设置为 on 的时候,表示统计信息会持久化存储。这时,默认的 N 是 20,M 是 10。
2、设置为 off 的时候,表示统计信息只存储在内存中。这时,默认的 N 是 8,M 是 16。
优化器会根据扫描行数和索引类型(主键索引、普通索引)综合权衡。
解决统计信息不准确
当统计信息不准确,可以通过 analyze table tablename 命令,可以用来重新统计索引信息.
索引选择异常和处理
方法1:使用 force index 强行选择一个索引。
缺点:写法不优美;索引名称变动可能很麻烦;变更的及时性、不够敏捷。
方法2:修改语句,引导使用期望的索引。
见索引实践->选错索引
方法3:新建索引或删除误用索引。
前缀索引
在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。
针对字符串字段如何加索引呢?
可以考虑前缀索引,对于前缀长度可以通过 count(distinct) 来评估。
| |
可能会损失区分度,设定可接受的损失比例 x%,计算 Lx=L*(1-x%),选择最小的Lx。 缺点:
1、可能会增加扫描行数
2、对覆盖索引有影响
无法利用覆盖索引对查询性能的优化。
索引实践
重建索引
表 T 定义:
| |
如果要重建索引 k,两个 SQL 语句可以这么写:
| |
如果要重建主键索引,也可以这么写:
| |
问题:两个重建索引的做法是否合理? 重建索引 k 的做法是合理的,可以达到节省空间的目的。但是,重建主键索引的过程不合理!不论是删除主键还是创建主键,都会将整个表重建。所以连着 重建索引 k 和 重建主键索引 两个动作,第一个就白做了。可以使用一个语句替代:
| |
附:首先解释重建索引的原因
索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。
多个索引合理性
表定义:
| |
历史原因,该表需要 a,b 做联合主键,查询场景:
| |
问题:“ca”、“cb”是否合理? 联合主键的聚簇索引组织顺序相当于 order by a,b,也就是先按 a 排序再按 b 排序,c 无序。
索引 c 的组织顺序是 cab
索引 ca 的组织是先按 c 排序,再按 a 排序,同时记录主键(注意这里主键部分只有 b),即 cab
索引 cb 的组织是先按 c 排序,再按 b 排序,同时记录主键(注意这里主键部分只有 a),即 cab
所以,结论就是 ca 可以去掉(或者去掉索引 c),cb 可以保留。
普通索引和唯一索引
唯一索引和普通索引在查询能力上没有差别,主要考虑的是更新性能的影响,建议尽量选择普通索引。
结合 change buffer 原理及其使用场景,如果数据更新(包括插入)之后会立即查询,应当关闭 change buffer。其它情况下,change buffer 和普通索引的配合使用,对于数据量大的表的更新优化还是挺明显的。
尤其使用机械硬盘时,change buffer 的收益非常显著。这种情况下,尽量使用普通索引,将 change buffer 尽量开大,以确保数据写入速度。
选错索引
隔离级别 RR,表结构:
| |
利用存储过程创建数据:
| |
额外操作:
| |
选错case1
实验过程:
| |
事务并发操作:
| Session A | Session B |
|---|---|
| start transaction with consistent snapshot; | |
| delete from t1; call idata(); | |
| explain select * from t1 where a between 10000 and 20000; | |
| commit; |
Session B 使用以下 SQL 语句,然后查看 慢查询日志 耗时。
| |
选错原因:不断删除和新增导致采样统计不准确。这里就是扫描行数的统计不准确。 场景:对应不断删除历史数据和新增数据的场景。
解决办法:可以考虑使用 force index。
为什么需要 Session A 的配合?
Session A 开启了事务并没有提交,RR 级别会创建一个一致性读视图。Session B 的删除会产生记录的新版本(空记录),同时会产生新的 undo log;一致性读视图需要的 undo log 不会删除,所以之前插入的 10 万行数据不能删除。因此之前的数据每一行数据都有两个版本,旧版本是 delete 之前的数据,新版本是标记为 deleted 的数据。不删除的情况下,记录还在数据页上占着空间,Session B 又把数据加回来,索引数据页出现大量分裂,导致 caredinality 不准。
| |
选错case2
| |
经过 expalin 分析,返回结果 key 字段显示,优化器选择了索引 b,而 rows 字段需要扫描的行数是 50128。 原因:
优化器认为使用索引 b 可以避免排序(b 本身是索引,已经有序),所以即使扫描行数多,也判定为代价更小。
解决办法:引导优化器选择期望的索引,修改 SQL 语句:
方法1:修改语义
| |
方法2:修改 limit:
| |
通过 limit 100 让优化器意识到,使用索引 b 代价很高。根据数据特征诱导优化器,不具备通用型。
字符串字段加索引
使用前缀索引
表定义:
| |
使用邮箱登录:
| |
考虑增加索引:
| |
如何选取前缀索引长度?
| |
可能会损失区分度,如设定可接受的损失比例 5%,计算 Lx=L*95%,选择最小的Lx。
使用倒序索引
如身份证同一个地区一般前 6 位相同,前缀索引区分度不高,相反后 6 位区分度较高。可以使用 count(distinct) 验证。
如果将身份证倒序存储,那么建立索引后查询时也倒序就可以提高效率(利用 reverse 函数)。
| |
再进一步,可以采用 倒序索引+前缀索引 的方式。 缺点:
不支持范围查询
使用hash字段
如新增额外的整型字段,如通过 crc32() 函数,一般在图片链接可以通过这种方式。
注意事项:
hash 可能冲突,SQL 的 where 条件需要再加上原字段的判断条件。
缺点:
不支持范围查询,只支持等值查询
学号加索引
邮箱作为登录名是 学号@gmail.com,学号规则:十五位的数字,前三位是所在城市编号、第四到第六位是学校编号、第七位到第十位是入学年份、最后五位是顺序编号。
问题:如何创建登录名的索引?
前 6 位是固定的,邮箱后缀相同,因此可以只存 入学年份和顺序编号,长度共为 9 位。
索引效率低下案例
month函数案例
表定义:
| |
表里记录了从 2016 年初到 2018 年底的所有数据,要统计发生在所有年份中 7 月份的交易记录总数。SQL 语句可能是:
| |
虽然 t_modified 字段上有索引,但语句却执行了很久。因为对字段做了函数计算就用不上索引。 进一步分析,条件是 where t_modified='2018-7-1’的时候为什么可以用上索引。实际上 B+ 树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。
注意:优化器并不是要放弃使用这个索引。
这个例子中放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引 t_modified,优化器对比索引大小后发现,索引 t_modified 更小,遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引 t_modified。
也可通过 explain 命令查看 key 字段确实使用了 t_modified 索引,rows 扫描了的行数也是所有行,Extra 字段的 Using index,表示的使用了覆盖索引。
改进 SQL(加上所有年份的 7 月):
| |
注意,对于不改变有序性的函数,优化器也不会优化,如:
| |
需要改成 where id = 10000 - 1 才可以。
隐式类型转换案例
复用上述 tradelog 表,分析下述 SQL 语句:
| |
tradeid 是有索引的,但是 explain 结果显示这条语句需要走全表扫描。因为 tradeid 的字段是 varchar(32),而输入的参数却是整型,所以需要做类型转换。 类型转换的规则
| |
这条语句的结果是 1。所以能确认 MySQL 的转换规则:在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字。 所以上面的 SQL 查询语句相当于:
| |
即触发了规则:对索引字段做函数操作,优化器会放弃走树搜索功能。 扩展延伸
id 是 int 类型,那么下面这条语句,是否导致全表扫描:
| |
验证: 上面验证了当字符串和数字作比较是将字符串转换成数字,索引隐式转换不会应用到字段上,所以可以走索引。另外,当字符串不能转换成数字时,都被转换成 0 了,下面的语句可以验证。
| |
隐式字符编码转换案例
复用上面的表 tradelog,在这基础上再加上下面的表:
Expand/Collapse Code Block
| |
如果要查询 id=2 的交易的所有操作步骤信息,SQL 语句可以是:
| |
explain 结果如下:

1、第一行显示优化器会先在交易记录表 tradelog 上查到 id=2 的行,这个步骤用上了主键索引,rows=1 表示只扫描一行;
2、第二行 key=NULL,表示没有用上交易详情表 trade_detail 上的 tradeid 索引,进行了全表扫描。
在这个执行计划里,是从 tradelog 表中取 tradeid 字段,再去 trade_detail 表里查询匹配字段。因此,我们把 tradelog 称为驱动表,把 trade_detail 称为被驱动表,把 tradeid 称为关联字段。
上面 SQL 的执行流程:
1、根据 id 在 tradelog 表里找到 id=2 这一行;
2、从这行中取出 tradeid 字段的值;
3、根据 tradeid 值到 trade_detail 表中查找条件匹配的行。explain 结果里第二行 key=NULL 表示:这个过程是通过遍历主键索引的方式,逐一判断 tradeid 的值是否匹配。
这时会发现第 3 步不符合预期。原因:两个表的字符集不同,一个是 utf8,一个是utf8mb4,所以做表连接查询时用不上关联子弹的索引。
将第 3 步单独改成 SQL 语句:
| |
$L2.tradeid.value 的字符集是 utf8mb4。utf8mb4 是 utf8 的超集,所以当这两个类型的字符串做比较时,MySQL 会先把 utf8 字符串转成 utf8mb4 字符集,再做比较。 即:
| |
CONVERT() 函数会将字符串转成 utf8mb4 字符集。还是触发了规则:对索引字段做函数操作,优化器会放弃走树搜索功能。 对比验证
| |
同上,第 3 步可以转换成:
| |
$R4.tradeid.value 的字符集是 utf8, 按照字符集转换规则,要转成 utf8mb4,所以这个过程就被改写成:
| |
这里的 CONVERT 函数是加在输入参数上,可以用上被驱动表的 traideid 索引。 这里也可以结合 expain 分析。
优化方案
1、修改 trade_detail 表数据集为 utf8mb4
2、如果业务数据量大无法做第 1 步的 DDL,那么实行修改 SQL:
| |
字段长度超长案例
| |
如果表中有 100 万行数据,其中有 10 万行数据的值是 ‘1234567890’,分析下述 SQL 语句执行过程:
| |
这条 SQL 语句执行很慢,流程如下:
1、传给引擎执行时做了字符截断。因为引擎里面这个行只定义了长度是 10,所以只截取前 10 个字节,就是’1234567890’进去做匹配;
2、这样满足条件的数据有 10 万行;
3、因为是 select *, 所以要做 10 万次回表;
4、但是每次回表后查出整行,到 server 层判断,b 的值都不是’1234567890abcd’;
5、返回结果是空。
虽然执行过程中可能经过函数操作,但是最终在拿到结果后,server 层还要做一轮判断。
索引优化
索引下推优化
合理借助联合索引。
覆盖索引优化
身份证号是市民的唯一标识。如果有根据身份证号查询市民信息的需求,只要在身份证号字段上建立索引就够了。而再建立一个(身份证号、姓名)的联合索引,是不是浪费空间?如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。
查询优化
优化顺序
1)尽量少作计算。
2)尽量少 join。
3)尽量少排序。
4)尽量避免 select *。
5)尽量用 join 代替子查询。
6)尽量少 or。
7)尽量用 union all 代替 union。
8)尽量早过滤。
9)避免类型转换。
10)优先优化高并发的 SQL,而不是执行频率低某些“大”SQL。
11)从全局出发优化,而不是片面调整。
12)尽可能对每一条运行在数据库中的SQL进行 Explain。
优化方向
- 加索引
- 避免返回不必要的数据
- 适当分批量进行
- 优化sql结构
- 分库分表
- 读写分离
慢查询优化步骤
0、先运行看看是否真的很慢,注意设置SQL_NO_CACHE
1、where条件单表查,锁定最小返回记录表。这句话的意思是把查询语句的where都应用到表中返回的记录数最小的表开始查起,单表每个字段分别查询,看哪个字段的区分度最高
2、explain查看执行计划,是否与1预期一致(从锁定记录较少的表开始查询)
3、order by limit 形式的sql语句让排序的表优先查
4、了解业务方使用场景
5、加索引时参照建索引的几大原则
6、观察结果,不符合预期继续从0分析
不走索引情况
1、索引列计算
2、前导模糊查询不能命中索引(如like '%xxx%',但是like 'xxx%'走索引!!)
3、正则表达式
4、字符串与数字比较(如 where a=1)
5、使用or(除非每个字段都是索引才走)
6、mysql估计全表扫描比使用索引快(约30%内的数据走索引?)
注意:范围查询如果有索引会走索引,但是多个范围,只有第一个范围查询走索引。
联合索引中,范围列后的字段不走索引
优化COUNT()查询
执行效果上:
count(*)包括了所有的列,相当于行数,在统计结果的时候,不会忽略列值为NULL。
count(1)包括了忽略所有列,用1代表代码行,在统计结果的时候,不会忽略列值为NULL 。
count(列名)只包括列名那一列,在统计结果的时候,会忽略列值为空(这里的空不是只空字符串或者0,而是表示null)的计数,即某个字段值为NULL时,不统计。
执行效率上:
列名为主键,count(列名)会比count(1)快。
列名不为主键,count(1)会比count(列名)快。
如果表多个列并且没有主键,则 count(1) 的执行效率优于 count(*)。
如果有主键,则 select count(主键)的执行效率是最优的。
如果表只有一个字段,则 select count(*)最优。
注意:MyISAM引擎统计了行数,不使用where时效率很高。
优化关联查询
1)using或者on的字段,在第二张表中应当为索引,第一张表中不需要创建索引
要理解优化关联查询的第一个技巧,就需要理解MySQL是如何执行关联查询的。当前MySQL关联执行的策略非常简单,它对任何的关联都执行嵌套循环关联操作,即先在一个表中循环取出单条数据,然后在嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为为止。然后根据各个表匹配的行,返回查询中需要的各个列。
2)确保任何的GROUP BY和ORDER BY中的表达式只涉及到一个表中的列,这样MySQL才有可能使用索引来优化。
limit优化
避免深度翻页。
比如:LIMIT 10000 20这样的查询,MySQL需要查询10020条记录然后只返回20条记录,前面的10000条都将被抛弃,这样的代价非常高。
优化这种查询一个最简单的办法就是尽可能的使用覆盖索引扫描,而不是查询所有的列。
方法一:
| |
方法二:
| |
其它优化的办法还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表中只包含主键列和需要做排序的列。
优化UNION
MySQL处理UNION的策略是先创建临时表,然后再把各个查询结果插入到临时表中,最后再来做查询。因此很多优化策略在UNION查询中都没有办法很好的时候。经常需要手动将WHERE、LIMIT、ORDER BY等字句“下推”到各个子查询中,以便优化器可以充分利用这些条件先优化。
除非确实需要服务器去重,否则就一定要使用UNION ALL,如果没有ALL关键字,MySQL会给临时表加上DISTINCT选项,这会导致整个临时表的数据做唯一性检查,这样做的代价非常高。当然即使使用ALL关键字,MySQL总是将结果放入临时表,然后再读出,再返回给客户端。虽然很多时候没有这个必要,比如有时候可以直接把每个子查询的结果返回给客户端。
**union、in、or 都能够命中索引**,建议使用 in。查询的CPU消耗:or > in >union
联合索引的使用有一个好处,就是索引的下一个字段是会自动排序的。
在进行优化的时候,需要暂时关闭数据库自带的缓存,这种缓存在平时查询时确实是个优势,但是在优化调试sql的时候很有必要关掉,在 select 后面加上 sql_no_cache 即可临时关上数据库缓存
groupby优化
利用GROUP BY统计大数据时,应当将查询与统计分离,优化查询语句。
优化前查询
| |
优化后查询
| |
group by 执行计划出现 using filesort
优化方案 group by field 后面加 order by null
(1) group by本质是先分组后排序【绝不是先排序后分组】
(2) group by默认会出现 Using filesort, 很多场景我只需要分组后的列【即被去重的列】, 众所周知这个东东会影响查询性能, 解决方法就是 group by ... order by null
(3) group by column 默认会按照column分组, 然后根据column升序排列; group by column order by null 则默认按照column分组,然后根据标的主键ID升序排列
实战操作
慢查询
| |
Reference
https://www.cnblogs.com/Chenjiabing/p/12600926.html
https://www.cnblogs.com/happyflyingpig/p/7662881.html
https://blog.csdn.net/fzy629442466/article/details/90711104
9.2.6 - 05.锁
背景
数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。
全局锁
全局锁定义
顾名思义,全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是
| |
当需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。 业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,对里面任何一个表做加字段操作,都是会被锁住的。
全局锁使用场景
全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。
如果是一张一张表分别做备份而不是锁全库,将会出现表之间的数据不一致。需要保证备份的库是在同一个逻辑时间点下。
官方自带的逻辑备份工具是 mysqldump。在 RR 隔离级别下,当 mysqldump 使用参数 –single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。
为什么使用 FTWRL 而不是 mysqldump
前提条件是引擎要支持 RR 隔离级别。MyISAM 就不支持,只能使用 FTWRL。
全库只读,为什么不使用 set global readonly=true 的方式
这种方式也能让全库进入只读状态。两个原因不使用:
1、readonly可能用作其他逻辑
有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,不建议使用。
2、在异常处理机制上有差异。
如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。
表级锁
MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。
表锁
表锁的语法是lock tables 表名 read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。
需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。
元数据锁
MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。
在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。防止表结构变更导致数据读写有问题。
1、读锁之间不互斥,可以有多个线程对同一张表增删改查
2、读写锁之间、写锁之间互斥,用来保证变更表结构操作的安全性。因此,如果多个线程同时修改表结构需要串行;变更表期间不允许对表增删改查。
如何安全修改表结构
首先我们要解决长事务,事务不提交,就会一直占着 MDL 锁。如果此时需改表结构,需要 MDL 写锁会被阻塞。之后所有要新申请 MDL 读锁的请求也会被阻塞,可能导致库的线程被打满。
在 MySQL 的 information_schema 库的 innodb_trx 表中,可以查到当前执行中的事务。如果要做 DDL 变更的表时有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务(kill可能不管用,因为新的请求可能又来了)。
阻塞了可以通过 show processlist 看到大量的Waiting for table metadata lock。真正阻塞的会话的 State 为空,Command 为 Sleep。
比较理想的机制,在 alter table 语句中设定等待时间,等待时间内拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句。
MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。需要确认是否支持该语法。
| |
当使用 NOWAIT 关键字时,则在执行ddl语句时,遇到 MDL 锁不进行等待。 当使用 WAIT 关键字时,等待 N
Online DDL
过程
1、拿MDL写锁
2、降级成MDL读锁
3、真正做DDL
4、升级成MDL写锁
5、释放MDL锁
1、2、4、5如果没有锁冲突,执行时间非常短。第3步占用了DDL绝大部分时间,这期间这个表可以正常读写数据,是因此称为“Online”。一般在第 1 步会被阻塞。
第 2 步退化成读锁的目的是为了实现 Online 更新,因为 MDL 读锁不会阻塞增删改操作。
不直接解锁是为了保护自己,禁止其他线程对这个表同时做 DDL。
额外说明的是,重建方法都会扫描原表数据和构建临时文件。对于大表来说该操作很消耗 IO 和 CPU 资源。Online DDL 可以考虑在业务低峰期使用,线上服务如果想要更安全的操作的话,建议使用 GitHub 开源的 gh-ost 来操作。
行锁
MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。
两阶段锁
在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
举例:
| 事务A | 事务B |
|---|---|
| begin; update t set k=k+1 where id=1; update t set k=k+1 where id=2; | |
| begin; update t set k=k+2 where id=1; | |
| commit; | |
| commit; |
事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。
死锁和死锁检测
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。
死锁举例:
| 事务A | 事务B |
|---|---|
| begin; update t set k=k+1 where id=1; | begin; |
| update t set k=k+2 where id=2; | |
| update t set k=k+1 where id=2; | |
| commit; | update t set k=k+2 where id=1; |
| commit; |
解决策略:
1、设置等待超时,通过参数 innodb_lock_wait_timeout 来设置
InnnoDB 中该参数默认值为 50s,在线业务无法接受。但设置太短容易误伤简单的锁等待。
2、发起死锁检测,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑(默认开启)。
一般采取主动死锁检测。但每个新来的线程被堵住都要判断是否因为自己的加入导致死锁,时间复杂度为 O(n)。需要消耗大量的 CPU 资源。因此可以看到 CPU 利用率很高,但是每秒却执行不了几个事务。
那么怎么解决热点行更新导致的性能问题?
1、如果确保业务一定不会出现死锁,可以关闭死锁检测。
2、控制并发度。比如同一行同时最多只能有 10 个线程在更新,那么死锁的检测成本很低。在数据库接入层(中间件/修改 MySQL 源码)做并发控制,而不是客户端!
3、将一行记录拆分成多行来减少行冲突,如账户金额,可以拆成多个子账户,金额相加即为总账户金额。(可能会使业务逻辑变复杂)
间隙锁
本节隔离级别无特别说明默认 RR。
**锁是加在索引上的,这是 InnoDB 的一个基础设定。**所以分析加锁过程就具体分析锁加在哪个索引上。
幻读的由来
表定义:
| |
| Session A | Session B | Session C | |
|---|---|---|---|
| T1 | begin; select * from t5 where d=5 for update; // Q1 result: (5,5,5) | ||
| T2 | update t5 set d=5 where id=0; | ||
| T3 | select * from t5 where d=5 for update; // Q2 result: (0,0,5), (5,5,5) | ||
| T4 | insert into t5 values (1,1,5); | ||
| T5 | select * from t5 where d=5 for update; // Q3 result: (0,0,5),(1,1,5),(5,5,5) | ||
| T6 | commit; |
Q3 读到 id=1 这一行的现象,被称为“幻读”。幻读指一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
幻读特别说明:
1、在 RR 隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此幻读在“当前读”下才会出现。
2、上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。
幻读的问题
1、语义的破坏
Session A 在 T1 时刻声明了“要把所有 d=5 的行锁住,不允许其它事务进行读写操作”。而实际上破坏了 Q1 的加锁声明,语义被破坏。
2、数据一致性的问题
锁的设计是为了保证数据的一致性。不止数据库内部数据状态在同一时刻的一致性,还包含数据和日志在逻辑上的一致性。
数据不一致举例:
| Session A | Session B | Session C | |
|---|---|---|---|
| T1 | begin; select * from t5 where d=5 for update; // Q1 update t5 set d=100 where d=5; | ||
| T2 | update t5 set d=5 where id=0; | ||
| T3 | select * from t5 where d=5 for update; // Q2 | ||
| T4 | insert into t5 values (1,1,5); | ||
| T5 | select * from t5 where d=5 for update; // Q3 | ||
| T6 | commit; |
如果按照前面的分析,数据应该是
T1: (5,5,100)
T2: (0,5,0)
T4: (1,1,5)
但是 binlog 内容是:
T2 时刻,Session B 事务提交,写入了两条语句;
T4 时刻,Session C 事务提交,写入了两条语句;
T6 时刻,Session A 事务提交,写入了 update t set d=100 where d=5 这条语句。
即:
| |
binlog 用作主从同步或者克隆库时,数据为:(0,0,100)、(1,1,100) 和 (5,5,100)。 这样就造成了数据的不一致。
解决办法自然就是将 扫描过程中碰到的行都加上写锁。即 T1 时刻 Session A 把所有行都加上写锁。这样 T2 时刻 Session B 就被阻塞了,需要等到 T6 时刻 Session A commit 之后才能执行。
但是此时仍然有问题,对于 Session C 的插入行无法阻塞。
id 为 1 的行在数据库中是 (1,1,5)
binlog 内容为:
| |
而按照 binlog 的执行顺序是 (1,1,100)。 造成这个问题的原因是给所有行都加上锁时,id=1 这一行还不在所以加不上行锁。
解决幻读
产生幻读的原因是行锁只能锁住行,但是新插入记录,要更新记录之间的“间隙”。因此为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。
当执行 select * from t where d=5 for update 时,就不止是给数据库中已有的 6 个记录加上行锁,同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。
与行锁的冲突间不一样,跟间隙锁存在冲突关系的是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。举例:
| Session A | Session B |
|---|---|
| begin; select * from t5 where c=7 lock in share mode; | |
| begin; select * from t5 where c=7 for update; |
这两个会话是不会冲突的,即 Session B 不会被阻塞。因为表中并没有 c=7 的数据,两个语句都是加的间隙锁,保证间隙不会插入数据。
间隙锁 + 行锁 = next-key
Gap-Lock是左开右开,next-key lock是左开右闭
间隙锁在 RR 隔离级别下才会生效。如果把隔离级别设置为 RC 就没有间隙锁了。但同时要解决可能出现的数据和日志不一致问题,需要把 binlog 格式设置为 row。
但间隙锁的出现,会影响并发度,比如带来了死锁的问题。如(分析略):
| Session A | Session B |
|---|---|
| begin; select * from t5 where id=9 for update; | |
| begin; select * from t5 where id=9 for update; | |
| insert into t5 values (9,9,9); (block) | |
| insert into t5 values (9,9,9); (error, Deadlock found) |
加锁规则
前提:MySQL 后面的版本可能会改变加锁策略,这里的规则只限于截止到现在的最新版本,即 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13
2个原则+2个优化+1个bug
原则 1:加锁的基本单位是 next-key lock(前开后闭区间)。
原则 2:查找过程中访问到的对象才会加锁。
优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
表:
| |
等值查询间隙锁
查询示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; update t5 set d=d+1 where id=7; | ||
| insert into t5 values(8,8,8); (blocked) | ||
| update t5 set d=d+1 where id=10; (Query OK) |
由于表 t 中没有 id=7 的记录,所以用加锁规则判断:
1、根据原则 1,加锁单位是 next-key lock,Session A 加锁范围就是 (5,10];
2、根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。
所以,Session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 Session C 修改 id=10 这行是可以的。
非唯一索引等值锁
查询示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; select id from t5 where c=5 lock in share mode; | ||
| update t5 set d=d+1 where id=5; (Query OK) | ||
| insert into t5 values(7,7,7); (blocked) |
Session A 要给索引 c 上 c=5 的这一行加上读锁。
1、根据原则 1,加锁单位是 next-key lock,因此会给 (0,5] 加上 next-key lock。
2、要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10] 加 next-key lock。
3、同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
4、根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 Session B 的 update 语句可以执行完成。
Session C 要插入一个 (7,7,7) 的记录,会被 Session A 的间隙锁 (5,10) 锁住。注意,这个例子中 lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样。 执行 for update 时,系统会认为接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
这个例子说明,锁是加在索引上的;同时,如果要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 Session A 的查询语句改成 select d from t where c=5 lock in share mode。
主键索引范围锁
思考,下面两个语句加锁范围:
| |
逻辑上,id 是 int 型,两条查语句肯定是等价的,但是它们的加锁规则不太一样。 查询示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; select * from t5 where id>=10 and id<11 for update; | ||
| insert into t5 values(8,8,8); (Query OK) insert into t5 values(13,13,13); (blocked) | ||
| update t5 set d=d+1 where id=15; (blocked) |
Session A 会加锁:
1、开始执行要找到第一个 id=10 的行,本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
2、范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。
所以,Session A 锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。
这里需要注意,首次 Session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
如果 Session A 是 select * from t5 where id=10 for update; ,那么 Session B 和 Session C 都不会阻塞。
非唯一索引范围锁
示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; select * from t5 where c>=10 and c<11 for update; | ||
| insert into t5 values(8,8,8); (blocked) insert into t5 values(13,13,13); (blocked) | ||
| update t5 set d=d+1 where c=15; (blocked) |
Session A 用字段 c 来判断,加锁规则跟 唯一索引范围锁 唯一的不同是:在第一次用 c=10 定位记录时,索引 c 上加了 (5,10] 这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则不会蜕变为行锁,因此最终加锁为:索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。
Session c 需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后查找。注意:如果是 update t5 set d=d+1 where id=15 是不阻塞的,因为这是用的主键索引更新没有锁住的索引 c。但如果是 update t5 set c=c+1 where id=15 仍然是阻塞的,因为更新了锁住的索引 c。
唯一索引范围锁bug
示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; select * from t5 where id>10 and id<=15 for update; | ||
| update t5 set d=d+1 where id=20; (blocked) | ||
| insert into t5 values(16,16,16); (blocked) |
Session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15]这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。
但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20]这个 next-key lock 也会被锁上。
Bug 范围:InnoDB(5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。)【待验证】
非主键唯一索引 bug 范围:InnoDB (<=8.0.21)【待验证】
注意 唯一索引 并不一定是 主键索引。
非唯一索引存在等值
先在索引 c 上增加一条 c=10 的记录:
| |
示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; delete from t5 where c=10; | ||
| insert into t5 values(12,12,12); (blocked) | ||
| update t5 set d=d+1 where c=15; (Query OK) |
Session A 在遍历的时候,先访问第一个 c=10 的记录。根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到 (c=15,id=15) 的间隙锁。
limit加锁
接着上面 非唯一索引存在等值 的情况,同样地这里也增加一条 c=10 的记录。
| Session A | Session B |
|---|---|
| begin; delete from t5 where c=10 limit2; | |
| insert into t5 values(12,12,12); (Query OK) |
Session A 的 delete 语句加了 limit 2。其实表 t5 里 c=10 的记录其实只有两条加不加 limit 2,删除的效果是一样的,但是加锁的效果却不同。可以看到Session B 的 insert 语句执行通过了,跟上面案例的结果不同。
这里 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间。(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。
经验:在删除数据的时候尽量加 limit。不仅可以控制删除数据的条数让操作更安全,还可以减小加锁的范围。
注意:binlog 格式为 statement时,删除语句where中有多个索引并且带limit可能会导致不同数据库之间,所选用的索引不一致而导致选到的数据不一致。
死锁示例
示例:
| Session A | Session B |
|---|---|
| begin; select id from t5 where c=10 lock in share mode; | |
| update t5 set d=d+1 where c=10; (blocked) | |
| insert into t5 values(8,8,8); (Query OK) | |
| Deadlock found |
分析:
1、Session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-key lock (5,10] 和间隙锁 (10,15);
2、Session B 的 update 语句也要在索引 c 上加 next-key lock (5,10] ,进入锁等待;
3、Session A 要再插入 (8,8,8) 这一行,被 Session B 的间隙锁(B 的加锁范围和 A 相同)锁住。由于出现了死锁,InnoDB 让 session B 回滚。
问题:Session B 的锁不是阻塞了吗?为什么还会有死锁?
Session B 加 next-key lock (5,10] 操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。(间隙锁之间不冲突,行锁与间隙锁才冲突)
order加锁
示例:
| Session A | Session B | Session C |
|---|---|---|
| begin; select * from t5 where c>=15 and c<=20 order by c desc lock in share mode; | ||
| insert into t5 values(8,8,8); (blocked) | ||
| insert into t5 values(11,11,11); (blocked) |
分析:
1、由于是 order by c desc,第一个要定位的是索引 c 上“最右边的”c=20 的行,所以会加上间隙锁 (20,25) 和 next-key lock (15,20]。
2、在索引 c 上向左遍历,要扫描到 c=10 才停下来,所以 next-key lock 会加到 (5,10],这正是阻塞 Session B 的 insert 语句的原因。
3、扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁,c=10 不满足条件就蜕化了。
所以 Session A 的 select 语句锁的范围就是:
1、索引 c 上 (5, 25);
2、主键索引上 id=15、20 两个行锁。
insert加锁*
表:
| |
insert select语句
insert … select 是很常见的在两个表之间拷贝数据的方法。在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。不加锁会出现主从不一致。
insert循环写入
语句1:
| |
这个语句的加锁范围是表 t41 索引 c 上的 (3,4] 和 (4,supremum] 这两个 next-key lock,以及主键索引上 id=4 这一行。 慢查询日志查到 Rows_examined=1,正好验证了执行这条语句的扫描行数为 1。
语句2:
| |
慢查询日志 Rows_examined 的值是 5。
| |
Extra 字段可以看到“Using temporary”字样,表示这个语句用到了临时表。即执行过程中,需要把表 t 的内容读出来,写入临时表。但 rows 为 1。
可以通过 show status like '%Innodb_rows_read%'; 看看 InnoDB 扫描的行数。
Expand/Collapse Code Block
| |
可以看到,这个语句执行前后,Innodb_rows_read 的值增加了 4。因为默认临时表是使用 Memory 引擎的,所以这 4 行查的都是表 t41,也就是说对表 t41 做了全表扫描。 执行过程:
1、创建临时表,表里有两个字段 c 和 d。
2、按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表读到 c 和 d 的值写入临时表。这时,Rows_examined=4。
3、由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中。这时,Rows_examined 的值加 1,变成了 5。
也就是这个语句会导致在表 t41 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据。
对于一边遍历数据,一边更新数据的情况,如果不使用临时表保存遍历的数据,可能会导致读到刚刚插入的记录,与语义不符。 优化:创建临时表,先插入临时表,再从临时表中取数据。
| |
如果 insert 和 select 的对象是同一个表,则有可能会造成循环写入。这种情况下需要引入用户临时表来做优化。
insert冲突加锁
举例(RR 下):
| Session A | Session B |
|---|---|
| insert into t41 values(10,10,10); | |
| begin; insert into t41 values(11,10,10); (Duplicate entry '10' for key 'c') | |
| insert into t41 values(12,9,9); (blocked) |
Session A 执行的 insert 语句,发生唯一键冲突时,并不只是简单地报错返回,还在冲突的索引上加了锁。一个 next-key lock 就是由它右边界的值定义的。这时 Session A 持有索引 c 上的 (5,10] 共享 next-key lock(读锁)。
官方文档 描述认为如果冲突的是主键索引就加记录锁,唯一索引才加 next-key lock。但实际上,这两类索引冲突加的都是 next-key lock。
按照提交的信息看这是mysql bug,已经修正。 (mysql 5.7.33)primary key 是不会block 的,唯一索引还是加的 next-key lock。
唯一键冲突导致死锁场景
| Session A | Session B | Session C | |
|---|---|---|---|
| T1 | begin; insert into t41 values(null,5,5); | ||
| T2 | insert into t41 values(null,5,5); | insert into t41 values(null,5,5); | |
| T3 | rollback; | (Deadlock found) | |
这个死锁产生的逻辑:
1、在 T1 时刻,启动 Session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁。
2、在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,加上读锁;同样地,Session C 也在索引 c 上,c=5 这一个记录上,加了读锁。
3、T3 时刻,Session A 回滚。这时 Session B 和 Session C 都试图继续执行插入操作,都要加上写锁。两个 session 都要等待对方的行锁,所以就出现了死锁。(B C有一个会被执行,另一个被回滚)
insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。
insert on duplicate
| |
会给索引 c 上 (5,10] 加一个排他的 next-key lock(写锁)。 insert into … on duplicate key update 这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。
执行语句的 affected rows 返回的如果是 2 很容易造成误解。实际真正更新的只有一行,只是在代码实现上,insert 和 update 都认为成功了,update 计数加了 1, insert 计数也加了 1。
最佳实践
如何查看死锁
执行 show engine innodb status 命令会输出很多信息,有一节 LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。
结果分成三部分:
(1) TRANSACTION,是第一个事务的信息;
(2) TRANSACTION,是第二个事务的信息;
WE ROLL BACK TRANSACTION (1),是最终的处理结果,如回滚了哪一个事务。
(更多信息略)
得到两个结论:
1、由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
2、会回滚成本更小的事务。如在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以 InnoDB 选择了回滚成本更小的 lock in share mode 语句。
如何查看锁等待
数据库记录为本文中的 t5。
| Session A | Session B |
|---|---|
| begin; select * from t5 where id>10 and id<15 for update; | |
| delete from t5 where id=10; (Query OK) | |
| insert into t5 values(10,10,10); (blocked) |
通过 show engine innodb status 查看信息,锁信息是在这个命令输出结果的 TRANSACTIONS 这一节

查看信息可以知道这是因为间隙锁阻塞了,原来的两个间隙 (5,10)、(10,15)变成了一个 (5,15)。
间隙是随着数据的改变而改变。
update示例
| Session A | Session B |
|---|---|
| begin; select c from t5 where c>5 lock in share mode; | |
| update t set c=1 where c=5; (Query OK) | |
| update t set c=5 where c=1; (blocked) |
Session A 的加锁范围是索引 c 上的 (5,10]、(10,15]、(15,20]、(20,25]和 (25,supremum]。注意:根据 c>5 查到的第一个记录是 c=10,因此不会加 (0,5]这个 next-key lock。
Session B 的第一个 update 语句,要把 c=5 改成 c=1,可以理解为两步:
1、插入 (c=1, id=5) 这个记录;
2、删除 (c=5, id=5) 这个记录。
索引 c 上 (5,10) 间隙是由这个间隙右边的记录,也就是 c=10 定义的。所以通过这个操作,Session A 的加锁范围就变成了 (1,10]、(10,15]、(15,20]、(20,25]和 (25,supremum]。
接下来 Session B 要执行 update t set c = 5 where c = 1 一样地可以拆成两步:
1、插入 (c=5, id=5) 这个记录;
2、删除 (c=1, id=5) 这个记录。
第一步试图在已经加了间隙锁的 (1,10) 中插入数据就被堵住了。
这个例子的间隙也是随着数据的改变而改变。
FAQ
备份时DDL会怎么样
问题:当备库用 mysqldump 工具 –single-transaction 做逻辑备份的时候,如果从主库的 binlog 传来一个 DDL 语句会怎么样?
备份过程关键语句:
| |
在备份开始的时候,为了确保 RR(可重复读)隔离级别,再设置一次 RR 隔离级别 (Q1); 启动事务,这里用 WITH CONSISTENT SNAPSHOT 确保这个语句执行完就可以得到一个一致性视图(Q2);
设置一个保存点,这个很重要(Q3);
show create 是为了拿到表结构 (Q4),然后正式导数据 (Q5),回滚到 SAVEPOINT sp,在这里的作用是释放 t1 的 MDL 锁 (Q6)。当然这部分属于“超纲”,上文正文里面都没提到。
DDL 从主库传过来的时间按照效果不同,定义了四个时刻。题目设定为小表,假定到达后,如果开始执行,则很快能够执行完成。
参考答案如下:
1、如果在 Q4 语句执行之前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。
2、如果在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
3、如果在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。
4、从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。
总结:
如果 mysqldump 备份的是整个 schema,某个小表t1只是该schema上其中有一张表
情况1:
master上对小表 t1 的 DDL 传输到 slave 去应用的时刻,mysqldump 已经备份完了t1表的数据,此时slave 同步正常,不会有问题。
情况2:
master上对小表 t1 的 DDL 传输到slave去应用的时刻,mysqldump 正在备份t1表的数据,此时会发生MDL 锁,从库上t1表的所有操作都会Hang 住。
情况3:
master 上对小表 t1 的 DDL 传输到slave去应用的时刻,mysqldump 还没对t1表进行备份,该DDL会在slave的t1表应用成功,但是当导出到t1表的时候会报“ERROR 1412 (HY000): Table definition has changed, please retry transaction” 错误,导致导出失败!
在备份期间,备份线程 mysqldump 用 RR ,而业务线程用的是 RC。同时存在两种事务隔离级别会不会有问题?
批量删除数据
删除一个表中的前 10000 条数据,有 3 种方法:
1、直接执行 delete from t limit 10000
2、在一个连接中循环执行 20 次 delete from t limit 500
3、在 20 个连接中同时执行 delete from t limit 500
答案:
第 2 种方式相对较好。
第 1 种方式单个语句占用时间长,持有锁时间长,大事务还会导致主从延迟。
第 3 种方式会人为造成锁冲突。
或者采取用某种条件将 10000 行数据天然分开,如主键id或业务条件(结合第3种)
空表是否有间隙
一个空表就只有一个间隙。
| Session A | Session B |
|---|---|
| create table t7(id int primary key)engine=innodb; begin; select * from t7 where id>1 for update; | |
| insert into t7 values(2); (blocked) | |
| show engine innodb status; |
Session A 加锁的范围就是 next-key lock (-∞, supremum]。

9.2.7 - 06.事务隔离
背景
MySQL 中,事务支持是在引擎层实现的。你现在知道,MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
事务特性
ACID(Atomicity、Consistency、Isolation、Durability)
原子性:满足原子操作单元,对数据的操作,要么全部执行,要么全部失败。
一致性:事务开始和完成,数据都必须保持一致,
隔离性:事务之间是相互独立的,中间状态对外不可见。
持久性:数据的修改是永久的。
事务隔离级别
事务并发问题
当数据库上有多个事务同时执行的时候,就可能出现下列问题:
1、脏读(dirty read)
A事务还未提交,B事务就读到了A事务的结果。(破坏了隔离性)
2、不可重复读(non-repeatable read)
A事务在本次事务中,对自己未操作过的数据,进行了多次读取,结果出现了不一致或记录不存在的情况(破坏了一致性,update和delete)
3、幻读(phantom read)
A事务在本次事务中,对自己未操作过的数据,进行了多次读取,第一次读取时记录不存在,第二次读取时却出现记录
为了解决这些问题,就有了“隔离级别”的概念。
隔离级别
隔离级别越高,效率就会越低。因此需要在二者之间寻找一个平衡点。
SQL 标准的事务隔离级别包括:
1、读未提交(read uncommitted)
一个事务还没提交时,它做的变更就能被别的事务看到
2、读已提交(read committed)
一个事务提交之后,它做的变更才会被其他事务看到
3、可重复读(repeatable read)
一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的
4、串行化(serializable )
对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
注意:这四个级别只是一个标准,各个数据库厂商,并不是完全按照这个标准来做的。
案例:不同隔离级别下读
| 事务A | 事务B |
|---|---|
| 启动事务, 查询得到值 1 | 启动事务 |
| 查询得到值 1 | |
| 将值 1 改成 2 | |
| 查询得到值 V1 | |
| 提交事务B | |
| 查询得到值 V2 | |
| 提交事务A | |
| 查询得到值 V3 |
针对这个案例,不同隔离级别会有不同的返回结果:
读未提交
虽然事务B还没有提交,但是事务A可以看到,V1 是 2,V2、V3都是2。
读已提交
事务A不可以看到事务B未提交的数据,因此 V1 是 1。事务B提交后事务A可以看到,因此 V2 是 2,V3 也是 2。
可重复读
V1 是 1,因为事务A在执行期间看到的数据前后必须是一致的,所以 V2 是 1,提交之后可见其它事务提交的数据,所以 V3 是 2。
串行化
事务B执行“将1改成2”时会被锁住,直到事务A提交后,事务B才可以继续执行。所以 V1、V2 是 1,V3 是 2。
DB默认级别
大部分数据库的默认隔离级别是“读已提交”,如 Oracle。
而MySQL 默认隔离级别为“可重复读”。
事务实现(InnoDB)
在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。
在“可重复读”隔离级别下,视图是在事务启动时创建的,整个事务存在期间都用这个视图。
在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。
这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;
而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
锁机制
阻止其他事务对数据进行操作,各个隔离级别主要体现在读取数据时加的锁和释放时机。
RU
事务读取的时候,不加锁
RC
事务读取的时候加行级共享锁(读到才加锁),一旦读完,立刻释放(并不是事务结束)。
RR
事务读取时加行级共享锁,直到事务结束才会释放。
SE
事务读取时加表级共享锁,直到事务结束时,才会释放。
其他还有一些不同,主要就这些。
MVCC机制
生成一个数据快照,并用这个快照来提供一定级别的一致性的读取,也成为了多版本数据控制。
实际就是[CAS版本控制』和『读写分离』的思想。主要作用于 RC 和 RR 级别。
MVCC
视图
在 MySQL 中,有两个“视图”概念:
1、一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
2、另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
快照
在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。
InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
如下图就是一个记录被多个事务连续更新后的状态。
行状态变更图

上图中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。
一致性视图
按照可重复读的定义,一个事务启动时,能够看到所有已经提交的事务结果。但是之后这个事务的执行期间,其他事务的更新对它不可见。
在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)
而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。
视图数据把所有的 row trx_id 分成了几种不同的情况。

[数据版本可见性规则]
对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
1、如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
2、如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
3、如果落在黄色部分,那就包括两种情况
a、若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
b、若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。(注意上面说的“活跃”:启动了但还没提交)
所以InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
1、版本未提交,不可见;
2、版本已提交,但是是在视图创建后提交的,不可见;
3、版本已提交,而且是在视图创建前提交的,可见。
换言之 一致性读 下:
1、对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
2、对于读提交,查询只承认在语句启动前就已经提交完成的数据。
而当前读,总是读取已经提交完成的最新版本。
当前读
更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)
除了 update 语句外,select 语句如果加锁(lock in share mode 或 for update),也是当前读。如:
| |
MVCC延伸
表结构变更(DDL)为什么不支持“可重复读”?这是因为表结构没有对应的行数据,也没有 row trx_id,因此只能遵循当前读的逻辑。
MySQL 8.0 已经可以把表结构放在 InnoDB 字典里了,也许以后会支持表结构的可重复读。
案例
无法更新数据
事务隔离级别为 RR。
| |
更新数据:0 rowsExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t;
+----+------+
| id | c |
+----+------+
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
+----+------+
4 rows in set (0.00 sec)
mysql> update t set c=0 where id=c;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
mysql> select * from t;
+----+------+
| id | c |
+----+------+
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
+----+------+
4 rows in set (0.00 sec)
问题:模拟该情况,并说明原理。 答案1:
| Session A | Session B |
|---|---|
| begin; select * from t; | |
| update t set c=c+1; | |
| update t set c=0 where id=c; select * from t; |
答案2:(类似答案1)
| Session A | Session B |
|---|---|
| begin; select * from t; | |
| begin; select * from t; | |
| update t set c=c+1; commit; | |
| update t set c=0 where id=c; select * from t; |
实战操作
事务启动方式
显示启动事务
| |
隐式启动事务
| |
注意:
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。
第一种启动方式,一致性视图是在执行第一个快照读语句时创建的;
第二种启动方式,一致性视图是在执行 start transaction with consistent snapshot 时创建的。
额外说明:
“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在 RC 隔离级别下,这个用法就没意义了,等效于普通的 start transaction。
因为 RC 级别下,事务开启后每一个语句执行前都会重新计算一个新视图,用这种方式解决脏读,但是有不可重复读的问题。和 RR 级别下创建视图和使用视图不一样。
FAQ
避免长事务影响
从应用开发端和数据库端来看:
应用开发端
1、确认是否使用了 set_autocommit=0。
可以通过开启 general_log,查询日志开启。(将其值改为 1)
方法一:
| |
关闭general log模式 命令行设置即可,无需重启 在general log模式开启过程中,所有对数据库的操作都将被记录 general.log 文件 方法二:
也可以将日志记录在表中 set global log_output='table' 运行后,可以在mysql数据库下查找 general_log 表
| |
2、确实是否有不必要的只读事务
有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
3、控制每个语句执行的最长时间
业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,避免单个语句意外执行太长时间。
数据库端
1、监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill
2、Percona 的 pt-kill 这个工具不错,推荐使用
3、在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
4、如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。
innodb_undo_tablespaces 是控制 undo 是否开启独立的表空间的参数。
为0表示:undo使用系统表空间,即ibdata1
不为0表示:使用独立的表空间,一般名称为 undo001 undo002,存放地址的配置项为:innodb_undo_directory 一般innodb_undo_tablespaces 默认配置为0,innodb_undo_directory默认配置为当前数据目录
Reference
https://github.com/Yhzhtk/note/issues/42
9.2.8 - MySQL技术内幕-InnoDB
体系结构与存储引擎
定义数据库与实例
数据库:物理操作系统文件或其他形式文件类型的集合。
实例:MySQL数据库由后台线程以及一个共享内存区组成
MySQL被设计为一个单进程多线程架构的数据库,与SQL Server比较类似。而Oracle是多进程的架构。
查看MySQL实例启动时,在哪些位置查找配置文件:
| |
可以看出,是按/etc/my.cnf -> /etc/mysql/my.cnf -> /url/local/mysql/etc/my.cnf -> ~/.my.cnf 的顺序读取配置文件,以最后读取到的配置文件为准。
InnoDB存储引擎
InnoDB体系架构
内存池负责工作:
1、维护所有进程/线程需要访问的多个内部数据结构
2、缓冲磁盘上的数据,方便快速读取,同时对磁盘文件的数据修改之前在这里缓冲
3、重做日志(redo log)缓冲
后台线程的作用:
1、负责刷新内存池中的数据,保证缓冲池中的内存缓存的是最近的数据
2、将已修改的数据文件刷新到磁盘文件,同时保证在数据库发生异常的情况下InnoDB能恢复到正常运行状态
后台线程
1、Master Thread
(核心线程)负责将缓冲池中的数据异步刷新到磁盘,保证数据一致性,包括脏页的刷新、合并插入缓冲、UNDO页的回收等
2、IO Thread
使用大量异步线程处理写IO请求。write/read/insert buffer/log IO thread
| |
可以通过命令SHOW ENGINE INNODB STATUS观察InnoDB的IO Thread。 3、Purge Thread
事务被提交后,其所使用的undolog可能不再需要,因此需要PurgeThread来回收已经使用并分配的undo页。
4、Page Cleaner Thread
作用:将之前版本中脏页的刷新操作都放入到单独的线程中来完成。目的:减轻原Master Thread的工作及对于用户查询线程的阻塞,进一步提高性能。
内存
1、缓冲池
缓冲池缓存的数据页类型有:索引页、数据页、undo页、插入缓冲(insert buffer)、自适应哈希索引(adaptive hash index)、InnoDB存储的锁信息(lock info)、数据字典信息(data dictionary)等。
缓冲池实例数(默认1,可配置):
| |
2、LRU、Free、Flush
LRU加入了midpoint位置,新读取到的页,虽然是最新访问的页,但并不是直接放入到LRU列表的首部,而是放入到LRU列表的midpoint位置。该算法称为midpoint insertion strategy。默认配置该位置在LRU列表长度的5/8处。由参数innodb_old_blocks_pct控制。innodb_old_blocks_time表示页读取到mid位置后需要等待多久才能被加入到LRU列表的热端。
命令(红色)结果Database pages表示LRU列表中页的数量。
page made young表示LRU列表中页移动到前端的次数。
脏页(LRU列表中的页被修改后,称为脏页)通过CHECKPOIN机制将脏页刷新回磁盘,而Flush列表中的页即为脏页。
脏页既存在于LRU列表中,也存在于Flush列表中。LRU列表用来管理缓冲池中页的可用性,Flush列表用来管理将页刷新回磁盘,二者互不影响。
Modified db pages 表示脏页的数量
元数据表可查看:
| |
Free为空闲列表
3、重做日志缓冲
redo log buffer
大小有配置参数innodb_log_buffer_size控制。通常8MB满足绝大部分应用,因为下列三种情况会将内容刷新到外部磁盘的重做日志文件中:
1)Master Thread每一秒将重做日志缓冲刷新到重做日志文件
2)每个事物提交是会将重做日志缓冲刷新到重做日志文件
3)当重做日志缓冲池剩余空间小于1/2时,重做日志缓冲刷新到重做日志文件
4、额外的内存池
对内存的管理是通过一种称为内存堆(heap)的方式进行的。内存分配时,需要从额外的内存池中进行申请,例如分配缓冲池,每个缓冲池中的帧缓冲(frame buffer)还有对应的缓冲控制对象(buffer control block)(记录LRU、锁、等待等信息)。所以申请很大的缓冲池时,也应考虑相应增加该值。
Buffer Pool的LRU算法
了解完了InnoDB的内存结构之后,我们来仔细看看Buffer Pool的LRU算法是如何实现将最近没有使用过的数据给过期的。
原生LRU
首先明确一点,此处的LRU算法和我们传统的LRU算法有一定的区别。为什么呢?因为实际生产环境中会存在全表扫描的情况,如果数据量较大,可能会将Buffer Pool中存下来的热点数据给全部替换出去,而这样就会导致该段时间MySQL性能断崖式下跌。
对于这种情况,MySQL有一个专用名词叫缓冲池污染。所以MySQL对LRU算法做了优化。
优化后的LRU
优化之后的链表被分成了两个部分,分别是 New Sublist 和 Old Sublist,其分别占用了 Buffer Pool 的3/4和1/4。

该链表存储的数据来源有两部分,分别是:
1、MySQL的预读线程预先加载的数据
2、用户的操作,例如Query查询
默认情况下,由用户操作影响而进入到Buffer Pool中的数据,会被立即放到链表的最前端,也就是 New Sublist 的 Head 部分。但如果是MySQL启动时预加载的数据,则会放入MidPoint中,如果这部分数据被用户访问过之后,才会放到链表的最前端。
这样一来,虽然这些页数据在链表中了,但是由于没有被访问过,就会被移动到后1/4的 Old Sublist中去,直到被清理掉。
Checkpoint技术
当前事务数据库系统普遍采用Write Ahead Log策略,即当事务提交时,先写重做日志,再修改页。宕机通过重做日志恢复数据,保证数据持久性。
Checkpoint(检查点)技术的目的解决如下问题:
1、缩短数据库的恢复时间
2、缓冲池不够用时,将脏页刷新到磁盘
3、重做日志不可用时,刷新脏页
宕机时,不需要重做所有的日志,Checkpoint之前的页都已经刷新回磁盘,大大缩短恢复时间。
重做日志不可用是因为其设计为循环使用,并不是让其无限增大。重做日志可以被重用的部分是指这些重做日志已经不再需要,即宕机时,恢复操作不需要这部分的重做日志,因此可以覆盖使用。若此时还需要使用,则必须强制产生Checkpoint,将缓冲池中的页至少刷新到当前重做日志的位置。
Checkpoint将缓冲池中的脏页刷回到磁盘。
两种Checkpoint:
1、Sharp Checkpoint
2、Fuzzy Checkpoint
Sharp Checkpoint发生在数据库关闭时将所有的脏页都刷新回磁盘,默认工作方式,即参数innodb_fast_shutdown=1
如果运行时使用Sharp Checkpoint性能受到很大影响。故使用Fuzzy Checkpoint只刷新一部分脏页,而不是刷新所有的脏页回磁盘。
几种情况的Fuzzy Checkpoint:
1、Master Thread Checkpoint
每秒或每十秒异步
2、FLUSH_LRU_LIST Checkpoint
保证LRU列表需要大约100多个空闲也可用,移除LRU列表尾端的页需要Checkpoint
3、Async/Sync Flush Checkpoint
重做日志不可用,保证重做日志循环使用
4、Dirty Page too much Checkpoint
脏页数量太多(配置参数:innodb_max_dirty_pages_pct)
Master Thread工作方式
1.0.x版本之前的Master Thread
具有最高的线程优先级别。内部由多个循环组成(根据数据库运行状态进行切换):
1、主循环(loop)
每秒一次的操作包括:
- 日志缓冲刷新到磁盘,即使事务还没有提交(总是)
- 合并插入缓冲(可能)
- 至多刷新100个InnoDB的缓冲池中的脏页到磁盘(可能)
- 如果当前没有用户活动,则切换到background loop(可能)
**即使某个事物还没有提交,InnoDB存储引擎仍然每秒会将重做日志缓冲中的内容刷新到重做日志文件。**所以解释为什么再大的事务提交(commit)时间也是很短的。
InnoDB存储引擎判断当前一秒内发生IO次数是否小于5次,是则认为当前IO压力小,可以执行合并插入缓冲的操作。
判断当前缓冲池中脏页的比例(buf_get_modified_ratio_pct,默认为90,代表90%),如果超过,则刷新100个脏页到磁盘。
每十秒的操作包括:
- 刷新100个脏页到磁盘(可能)
- 合并至多5个插入缓冲(总是)
- 将日志缓冲刷新到磁盘(总是)
- 删除无用的Undo页(总是)
- 刷新100个或者10个脏页到磁盘(总是)
判断过去10秒之内磁盘IO操作是否小于200次,是则认为磁盘IO具备操作能力,因此将100个脏页刷新到磁盘。
接着合并插入缓冲操作(总是)。
之后将日志缓冲刷新到磁盘(总是)(和每秒操作一致)。
接着执行full purge操作,即删除无用的Undo页。判断是否可以删除,可以则立即删除。
然后判断缓冲池脏页比例(buf_get_modified_ratio_pct),超过70%,则刷新100个脏页到磁盘,否则刷新10个。
2、后台循环(backgroup loop)
若当前没有用户活动(数据空闲时)或者数据库关闭(shutdown),就会切换到这个循环。backgroup loop会执行以下操作:
- 删除无用的Undo页(总是)
- 合并20个插入缓冲(总是)
- 跳回到主循环(总是)
- 不断刷新100个页直到符合条件(可能,跳转到flush loop中完成)
3、刷新循环(flush loop)
若flush loop也没有事可以做,会切换到suspend loop,将Master Thread挂起,等待事件的发生。若用户启用(enable)InnoDB存储引擎,却未使用任何表,Master Thread总是处于挂起状态。
4、暂停循环(suspend loop)
1.2.x版本之前的Master Thread
1.0.x版本之前对于IO有限制,在缓冲池想磁盘刷新时做了硬编码(hard coding),固态硬盘(SSD)出现,则限制了对磁盘IO的性能,尤其是写入。
分析:无论何时,最大只会刷新100个脏页到磁盘,合并20个插入缓冲。如果是写密集每秒可能产生大于100个脏页,如果产生大于20个插入缓冲,Master Thread可能忙不过来或者说慢。
从1.0.x版本提供参数innodb_io_capacity表示磁盘吞吐量,默认200。对于刷新到磁盘页的数量,会按照该参数的百分比来控制。规则如下:
- 合并插入缓冲时,数量为该参数的5%
- 从缓冲区刷新脏页时,数量为该值
参数innodb_max_dirty_pages_pct默认值90,即脏页占缓冲池90%。该值太大,内存很大时DB压力很大,刷新脏页速度很慢,数据恢复时间更久。
1.0.x版本提供默认值75,和Google测试的80最接近。这样既可以加快刷新脏页的频率,又能保证磁盘IO的负载。(太小增加磁盘的压力)
增加参数innodb_adaptive_flushing(自适应地刷新)。原来刷新规则:脏页在缓冲池所占比例小于innodb_max_dirtypages_pct时,不刷新脏页;大于时刷新100个脏页。引入该参数后,buf_flush_get_desired_flush_rate函数通过判断产生重做日志(redo log)的速度决定最适合的刷新脏页数量。因此脏页比例小于innodb_max_dirtypages_pct时,也会刷新一定量的脏页。
之前每次full purge时,最多回收20个Undo页,1.0.x版本引入参数innodb_purge_batch_size,控制每次full purge回收的Undo页的数量。默认值为20。
1.0.x版本在性能方面取得了极大的提高。
1.2.x版本的Master Thread
对于刷新脏页的操作,从Master Thread线程分离到一个单独的Page Cleaner Thread,减轻主线程工作,提高并发。
InnoDB关键特性
1、插入缓冲(Insert Buffer)
性能提升
1)Insert Buffer
对于非聚集索引的插入或更新操作,不是每一次直接插入到索引页中,而是先判断插入的非聚集索引是否在缓冲池中,若在直接插入,否则先放到一个Insert Buffer对象中。然后以一定的频率和情况进行Insert Buffer和辅助索引页子节点的merge(合并)操作。这通常能将多个插入合并到一个操作中(在一个索引页中),大大提高非聚集索引插入的性能
使用需要满足两个条件:
- 索引是辅助索引
- 索引不是唯一的
问题:若宕机有大量的Insert Buffer并没有合并到实际的非聚集索引中,恢复需要大量时间(甚至几小时)。修改占用缓冲池大小的比例
2)Change Buffer
1.0.x版本引入了Change Buffer,可将其视为Insert Buffer的升级。可以对DML操作(INSERT/ DELETE/UPDATE)都进行缓冲,分别是Insert Buffer、Delete Buffer、Purge Buffer。
适用条件:非唯一的辅助索引。
UPDATE操作可分为两个过程:
- 将记录标记为已删除(Delete Buffer)
- 真正将记录删除(Purge Buffer)
参数innofb_chage_buffering用来开启各种buffer选项。可选inserts/deletes/purges /changes/all/none(默认all)。参数innodb_change_buffer_max_size控制Change Buffer最大使用内存数量。默认25,表示最多使用25%的缓冲池内存空间,最大有效值50。
3)Insert Buffer的内部实现
其数据结构是一颗B+树。MySQL4.1之前的版本中每张表都有一颗Insert Buffer B+树,现在版本全局只有一颗。存放在共享空间中,默认也就是ibdata1中。因此试图通过独立表空间idb文件恢复表中数据时,往往会导致CHECK TABLE失败。因为表的辅助索引中的数据可能还在Insert Buffer中,也就是共享空间中,所以还需要进行REPAIR TABLE操作来重建表上所有的辅助索引。(详情见P51)
4)Merge Insert Buffer
Insert Buffer的记录合并(merge)到真正的辅助索引中
该操作可能发生在以下几种情况下:
- 辅助索引页被读取到缓冲池时
- Insert Buffer Bitmap页追踪到该辅助索引页已无可用空间时
- Master Thread
2、两次写(Double Write)
可靠性。
解决诸如部分写失效(partial page write)等问题。
为什么不使用重做日志恢复?因为重做日志记录的是对页的物理操作,如偏移量xx写AA。如果页本身损坏,重做没有意义。即应用重做日志前,需要一个页的副本,当写入失效发生时,先通过页的副本还原该页,再进行重做,这就是doublewrite。
doublewrite由两部分组成:
- doublewrite buffer(2MB)
- 物理磁盘上共享表空间中连续的128个页,即2个区(extent),大小同样为2MB。 在对缓冲池的脏页进行刷新时,并不直接写磁盘,而是会通过memcpy函数将脏页先复制到内存中的doublewrite buffer,之后通过doublewrite buffer再分两次,每次1MB顺序写入共享表空间的物理磁盘上(顺序文件),然后调用fsync函数,同步磁盘(真正的数据文件.ibd),避免缓冲写带来的问题。
| |
innodb_dblwr_pages_written为写了的页数,innodb_dblwr_writes为实际写入次数,其比例小于64:1则说明系统写入压力不高。 参数skip_innodb_doublewrite可以禁止doublewrite功能,从服务器提高性能可以考虑,主服务器确保开启双写服务。
诸如ZFS文件系统本身提供了部分写失效的防范机制,可以不启用双写
理解
这时利用redo log (页(数据块)的物理操作)来恢复已经损坏的数据块是无效的!数据块的本身已经损坏,再次重做依然是一个坏块!所以此时需要一个数据块的副本来还原该损坏的数据块,再利用重做日志进行其他数据块的重做操作,这就是doublwrite的原因作用!
理解可参考:http://jockchou.github.io/blog/2015/07/23/innodb-doublewrite-buffer.html
3、自适应哈希索引(Adaptive Hash Index,AHI)
(Innodb存储引擎会监控对表上二级索引的查找,如果发现某二级索引被频繁访问,二级索引成为热数据,建立哈希索引可以带来速度的提升)

InnoDB存储引擎会监控对表上各索引页的查询。如果观察到建立哈希索引可以带来速度提升,则建立哈希索引,称之为自适应哈希索引。AHI是通过缓冲池的B+树页构造出来,建立速度很快,不需要对整张表构建哈希索引。
要求:对这个页的连续访问模式必须是一样的。
参数innodb_adaptive_hash_index禁用或启动此特性。默认开启。
缺点:
- 会占用innodb buffer pool
- 只能等值查找
4、异步IO(Async IO)
除了异步,还有一个优势是IO Merge操作。
1.1.x之前,AIO通过代码模拟实现。1.1.x提供了内核级别AIO的支持(Native AIO,mac系统不支持)。需要libaio库支持。
参数innodb_use_native_aio用来控制是否启用Native AIO,Linux系统下默认ON。
5、刷新临接页(Flush Neighbor Page)
当刷新一个脏页时,检测该页所在区(extent)的所有页,如果是脏页,那么一起刷新。通过AIO将多个IO写入操作合并为一个。
两个问题:
1、是否可能将不怎么脏的页进行写入,该页很快又会变成脏页?
2、固态硬盘有较高的IOPS,也需要该特性?
1.2.x版本开始提供innodb_flush_neighbors控制是否启用该特性。建议传统机械硬盘启动,固态硬盘不启用。
启动、关闭与恢复
参数innodb_fast_shutdown影响:
- 0表示DB关闭时,需完成所有的full purge和merge insert buffer,并且将所有的脏页刷新回磁盘
full purge:删除无用的Undo页 merge insert buffer:Insert Buffer的记录合并到真正的辅助索引中
- 1为默认值,不需要完成full purge和merge insert buffer,但将所有脏页刷新会磁盘
- 2两者皆不,而是将日志都写入日志文件,下次启动,会进行恢复操作(recovery)
kill关闭DB,也会进行恢复操作
参数innodb_force_recovery,略
文件
参数文件
日志文件
1、错误日志
| |
2、慢查询日志
参数long_query_time设置运行时间阈值,超过(不包括等于)则记录在慢查询日志中,默认值为10(秒)。
参数log_slow_queries为记录开关。
参数log_queries_not_using_indexes,如果运行语句没有使用索引,是否记录到该日志开关。
3、查询日志
4、二进制日志
记录了对DB执行更改的所有操作,但不包括SELECT和SHOW这类操作
若操作本身并没有导致DB发生变化,那么可能也会写入二进制日志
| |
二进制日志作用:
- 恢复(recovery)
- 复制(replication)
- 审计(audit):判断是否有进行注入的攻击
套接字文件
UNIX系统本地连接MySQL可采用UNIX域套接字方式,需要一个套接字(socket)文件,一般在/tmp目录下,名为mysql.sock。
pid文件
默认位于数据库目录下,文件名为主机名.pid
表结构定义文件
每个表都有一个以frm为后缀的文件,记录表结构定义,视图也有。可直接只用cat查看
InnoDB存储引擎文件
1、表空间文件
2、重做日志文件
表
索引组织表
表根据主键顺序组织存放的,这种存储方式成为索引组织表(index organized table)。
如果没有显式地定义主键,InnoDB则会按如下方式选择或创建主键:
1、首先判断表中是否有非空的唯一索引(Unique NOT NULL),如果有,则该列即为主键。
2、如果不符合上述条件,InnoDB自动创建一个6字节大小的指针
当表中有多个非空唯一索引时,会选择建表时第一个定义的非空唯一索引为主键。(定义索引的顺序,而不是建表时列的顺序。
| |
_rowid可以显示表的主键,但只能用于查看单个列为主键的情况
InnoDB逻辑存储结构
所有数据被逻辑地存放在一个空间中,称之为表空间(tablespace)。表空间又由段(segment)、区(extent)、页(page)组成。页在一些文档中有时也称为块(block)。
表空间
共享表空间iddata1,即所有数据都存放在该表空间中。若启用参数innodb_file_per_table,则每张表内的数据可以单独放到一个表空间内。
注意:每张表的表空间内存放的只是数据、索引和插入缓冲Bitmap页,其他类的数据,如回滚(undo)信息,插入缓冲索引页、系统事务信息,二次写缓冲(Double write buffer)等还是存放在原来的共享表空间内。
所以即使启动该参数,共享表空间还是不断增大。
段
表空间是由各个段组成的,常见的段有数据段、索引段、回滚段等。
InnoDB存储引擎表示索引组织的(index organized),因此数据即索引,索引即数据。
数据段:B+树的叶子节点
索引段:B+树的非叶子节点
对段的管理是引擎自身完成的。
区
区是由连续页组成的空间,在任何情况下每个区的大小都为1MB。为了保证区中页的连续性,InnoDB存储引擎一次从磁盘中申请4-5个区。默认情况下页大小为16KB,即一个区中共有64个连续的页。
1.0.x版本开始引入压缩页,页大小可通过参数KYE_BLOCK_SIZE设置为8KB/4KB/2KB,因此每个区对应页的数量为128、256、512。
1.2.x版本新增参数innodb_page_size,可将默认页大小置为4K/8K,但页中的数据库不是压缩,此时区中页的数量同样为256、128。
页
页(page)也称为块,是InnoDB磁盘管理的最小单位。
1.2.x版本新增参数innodb_page_size,可将页的大小设置为4K/8K/16K,设置完成,则所有表中页的大小都为该值,不可以对其再次进行修改。除非通过mysqldump导入和导出操作来产生新的库。
常见的页类型有:
- 数据页(B-tree Node)
- undo页(undo Log Page)
- 系统页(System Page)
- 事务数据页(Transaction system Page)
- 插入缓冲位图页(Insert Buffer Bitmap)
- 插入缓冲空闲列表页(Insert Buffer Free List)
- 未压缩的二进制大对象页(Uncompressed BLOB Page)
- 压缩的二进制大对象页(compressed BLOB Page)
行
InnoDB引擎是面向行(row-oriented),也就是数据时按行进行存放的。每个页存放的行记录有硬性指标,最多允许存放16KB/2-200行,即7992行记录。
InnoDB行记录格式
Compact行记录格式
Redundant行记录格式
行溢出数据
MySQL数据库的VARCHAR类型可以存放65535字节。通过测试最长长度为为65532,因为有别的开销。(字符集为latin1,UTF-8、GBK不一样)
注意如果没有讲SQL_MODE设为严格模式,或许可以建立表,但会抛出warning。自动将VARCHAR类型转换成了TEXT类型。
Compressed和Dynamic行记录格式
CHAR的行结构存储
InnoDB数据页结构
InnoDB数据页由以下7个部分组成:
- File Header(文件头)
- Page Header(页头)
- Infimun和Supremum Records
- User Records(用户记录,即行记录)
- Free Space(空闲空间)
- Page Directory(页目录)
- File Trailer(文件结尾信息)
Named File Formats机制
约束
数据完整性
约束的创建和查找
约束和索引的区别
对错误数据的约束
ENUM和SET约束
触发器与约束
外键约束
视图
分区表
索引与算法
InnoDB存储引擎索引概述
常见索引:
- B+树索引
- 全文索引
- 哈希索引
常常被忽略的问题:B+树索引并不能找到一个给定键值的具体行,B+树索引能找到的只是被查找数据行所在的页。然后数据库通过把页读入到内存,再在内存中进行查找,最后得到要查找的数据。
数据结构与算法
二分查找法
二叉查找树和平衡二叉树
B+树
B+树索引
1、聚集索引
表中数据按照主键顺序存放
2、辅助索引
3、B+树索引的分裂
4、B+树索引的管理
5、Cardinality值
怎么查看索引是否是高选择性(举例,性别不属于高选择性。取值范围广,几乎没有重复,即高选择性)。Cardinality值表示索引中不重复记录数量的预估值。
注意,这是个预估值,不是准确值。
数据库对于Cardinality的统计都是通过采样(Sample)的方法来完成。
InnoDB存储引擎内部对更新Cardinality信息的策略为:
- 表中1/16的数据已发生过变化
- stat_modified_counter > 2 000 000 000
B+树索引的使用
不同应用中B+树索引的使用
联合索引
覆盖索引
即从辅助索引中就可以得到查询的记录,而不需要查询聚集索引中的记录。
优化器选择不适用索引的情况
多发生在范围查找、JOIN链接操作等情况下
索引提示
显式地告诉优化器使用哪个索引。
Multi-Range Read优化
好处:
- MRR是数据访问变得较为顺序。在查询辅助索引时,首先根据得到的查询结果按照主键进行排序,并按照主键排序的顺序进行书签查找
- 减少缓冲池中页被替换的次数
- 批量处理对键值的查询操作
MRR的工作方式:
- 将查询得到的辅助索引键值存放于一个缓存中,这时缓存的数据时根据辅助索引键值排序的
- 将缓存中的键值根据RowID进行排序
- 根据RowID的排列顺序来访问实际的数据文件
Index Condition Pushdown(ICP)优化
MySQL5.6开始支持。
将WHERE的部分过滤操作放在了存储引擎层。大大减少上层SQL层对记录的索取(fetch),提高性能。
哈希算法
全文索引
1.2.x版本开始支持全文索引。
锁
InnoDB引擎里面的锁
一致性非锁定读
一致性非锁定读(consistent nonlocking read)是指InnoDB存储引擎通过行多版本控制(mutil versioning)的方式来读取当前执行时间数据库中行的数据。
一致性锁定读
两种一致性的锁定读(locking read)操作:
1、SELECT ... FOR UPDATE
2、SELECT ... LOCK IN SHARE MODE
第一种操作对读取的行记录加一个X锁,其他事务不能对已锁定的行加上任何锁。
第二种操作对读取的行记录加S锁,其他事务可以向被锁定的行加S锁,但如果加X锁,会被阻塞。
自增长与锁
在InnoDB存储引擎的内存结构中,对每个含有自增长值得表都有一个自增长计数器(auto-increment counter)。
| |
这种实现方式称作AUTO-INC Locking。这种锁其实是采用一种特殊的表锁机制,为了提高插入的性能,锁不是在一个事务完成后才释放,而是在完成对自增长值插入的SQL语句后立即释放。 问题:
1、并发插入性能差,事务必须等待前一个插入的完成(虽然不用等待事务的完成)
2、对于INSERT ... SELECT的大数据两的插入会有插入性能问题
MySQL 5.1.22版本开始,InnoDB提供了一种轻量级互斥量的自增长实现机制,大大提高了自增长值插入的性能。并提供参数innodb_autoinc_lock_mode控制自增长模式,默认值为1。
首先介绍插入类型:
1、insert-like
所有的插入语句,如INSERT、REPLACE、INSERT...SELECT、REPLACE...SELECT、LOAD DATA等
2、simple inserts
插入前能确定插入行数的语句,如INSERT...SELECT、REPLACE...SELECT、LOAD DATA
不包括INSERT...ON DUPLICATE KEY UPDATE这类语句
3、bulk inserts
插入前不能确定插入行数的语句,如INSERT...SERLECT,REPLACE...SELECT,LOAD DATA
4、mixed-mode inserts
插入中有一部分是自增长的,有一部分是确定的。如INSERT INTO t1(c1, c2) VALUES (1, 'a'), (NULL, 'b');也可以是指INSERT...ON DUPLICATE KEY UPDATE
上述参数innodb_autoinc_lock_mode以及各个设置下对自增的影响,共有三个有效值可以设定,即0、1、2,具体说明如下:
值为0:
MySQL5.1.22版本之前自增长的实现方式,即通过表锁的AUTO-INC Locking。
值为1:
默认值。
对于“simple inserts”,该值会用互斥量(mutex)去对内存中的计数器进行累加的操作。
对于“bulk inserts”,还是使用传统表锁的AUTO-INC Locking方式。
在这种配置下,如果不考虑回滚操作,对于自增值列的增长还是连续的。并且在这种方式下,statement-based方式的replication还是能很好地工作。需要注意的是,如果已经使用AUTO-INC Locking方式去产生自增长的值,而这时需要再进行“simple inserts”的操作时,还是需要等待AUTO-INC Locking的释放。
值为2:
该模式下,对于所有的“INSERT-like”自增长值的产生都是通过互斥量,而不是AUTO-INC Locking的方式。显然这是性能最高的方式。但在并发插入的情况下,自增长的的值可能不是连续的。
最重要的是,基于Statement-Base Replication会出现问题。因此,使用该模式任何时候都应该使用row-base replication。这样才能保证最大的并发性能及replication主从数据的一致。
InnoDB自增长的实现与MyISAM不同,MyISAM存储引擎是表锁设计,自增长不用考虑并发插入问题。因此在master上用InnoDB,在slave上用MyISAM的replication架构下,用户必须考虑这种情况。
InnoDB中,自增长的列必须是索引,同时必须是索引的第一个列,如果不是则会抛出异常,而MyISAM没有这个问题。
外键和锁
InnoDB会自动对其加一个索引,这样可以避免表锁——这比Oracle做得好,Oracle不会自动添加索引,用户必须自己手动添加,这也导致了Oracle可能产生死锁。
对于外键值的插入或者更新,首先需要查询父表中的记录,即SELECT父表。但是对于父表的SELECT操作,不是使用一致性非锁定读的方式,因为这样会发生数据不一致的问题,因此这时使用的是SELECT...LOCK IN SHARE MODE方式,即主动对父表加一个S锁。如果这时父表上已经加了X锁,子表上的操作会被阻塞。
锁的算法
行锁的3种算法
InnoDB存储引擎有3种行锁的算法,分别是:
- Record Lock:单个行记录上的锁
- Gap Lock:间隙锁,锁定一个范围,但不包括记录本身
- Next-Key Lock:Gap Lock+Record Lock,锁定一个范围,并且锁定记录本身
当查询的索引含有唯一属性时,InnoDB会对Next-Key Lock进行优化,将其降级为Record Lock,即仅锁住索引本身,而不是范围。若是辅助索引,其加上的是Next-Key Lock。
用户可以通过以下两种方式来显式地关闭Gap Lock:
- 将事务的隔离级别设置为READ COMMITTED
- 将参数innodb_locks_unsafe_for_binlog设置为1
在上述的配置下,除了外键约束和唯一性检查依然需要的Gap Lock,其余情况仅使用Record Lock进行锁定。但是,上述设置破坏了事务的隔离性,并且对于replication,可能会导致主从数据的不一致。此外,从性能上来看,READ COMMITTED也不会优于默认的事务隔离级别READ REPEATEABLE。
解决Phantome Problem
Phantom Problem是指在同一事务下,连续执行两次同样的SQL语句可能导致不同的结果,第二次SQL语句可能会返回之前不存在的行。
在默认的事务隔离级别下,即REPEATABLE READ下,InnoDB采用Next-Key Locking机制来避免Phantom Problem(幻象问题)。其他DB可能需要在SERIALIZABLE的事务隔离级别下才能解决该问题。
锁问题
脏读
不可重复读
丢失更新
阻塞
死锁
锁升级
锁升级(Lock Escalation)是指当前锁的粒度降低。比如,DB把一个表的1000个行锁升级为一个页锁,或者将页锁升级为表锁。这种升级保护了系统资源,防止系统用太多的内存来维护锁,在一定程度上提高了效率。但锁升级带来的一个问题是因为锁粒度的降低而导致并发性能的降低。
事务
事务的实现
redo
undo
purge
delete和update操作可能并不直接删除原有的数据。例如delete,将主键列等于xx的记录delete flag设置为1,记录并没有被删除,即记录还存在于B+树中。其次,对辅助索引没有做处理,甚至没有产生undo log。而真正删除这行记录的操作其实被“延时”了,最终在purge操作中完成。
purge用于最终完成delete和update操作。这样设计是因为InnoDB支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事务可能正在引用这行,故InnoDB需要保存记录之前的版本。而是否可以删除该跳记录通过purge来进行判断。若该行记录已不被任何其他事务引用,那就可以进行真正的delete操作。
因为undo log可重用没有顺序,所以使用history list记录了顺序,然后从undo page中找undo log。这样是为了避免大量的随机读取操作,从而提高purge的效率。
全局动态参数innodb_purge_batch_size用来设置每次purge操作需要清理的undo page数量。
group commit
一次fsync可以确保多个事务日志被写入文件。InnoDB事务提交时会进行两个阶段的操作:
1)修改内存中事务对应的细腻,并且将日志写入重做日志缓冲
2)调用fsync将确保日志都从重做日志缓冲写入磁盘
问题:
InnoDB1.2版本之前,开启二进制后,该功能失效,从而导致性能下降。并且在线环境多使用replication环境,因此二进制日志的选项基本都为开启状态,因此该问题显著。
原因:
开启二进制日志后,为了博阿正存储引擎层中的事务和二进制日志的一致性,二者之间使用了两阶段事务,其步骤如下:
1)当事务提交时InnoDB存储引擎进行prepare操作
2)MySQL数据库上层写入二进制日志
3)InnoDB存储引擎层将日志写入重做日志文件
a)修改内存中事务对应的信息,并且将日志写入重做日志缓冲
b)调用fsync将确保日志都从重做日志缓冲写入磁盘
为了保证MySQL数据库上层二进制日志的写入顺序和InnoDB层的事务提交顺序一致,MySQL数据内部使用了prepare_commit_mutex这个锁。但是在启用这个锁之后,步骤3)的步骤a)步不可以在其他事务执行步骤b)时进行,从而导致group commit失效。
解决方案:
Binary Log Group Commit(BLGC),步骤如下:
- Flush阶段,将每个事务的二进制日志写入内存中
- Sync阶段,将内存中的二进制日志刷新到磁盘,若队列中有多个事务,那么仅一次fsync操作就完成了二进制日志的写入,这就是BLGC
- commit阶段,leader根据顺序调用存储引擎层事务的提交,InnoDB存储引擎本就支持group commit,因此修复了原先由于锁prepare_commit_mutex导致group commit失效的问题
事务控制语句
隐式提交的SQL语句
以下SQL语句会产生一个隐式的提交操作,即执行完这些语句后会有一个隐式的COMMIT操作:
- DDL语句
- 用来隐式地修改MySQL架构的操作:CREATE USER、DROP USER、GRANT、RENAME USER、REVOKE、SET PASSWORD
- 管理语句:ANALYZE TABLE、CACHE INDEX、CHECK TABLE、LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE
对于事务操作的统计
每秒事务处理的能力(Transaction Per Second,TPS),计算TPS的方法是(com_commit+com_rollback)/time,但是利用这个方法计算的前提是:所有事务必须都是显式提交,如果存在隐式提价和回滚(默认autocommit=1),不会计算到com_commit和com_rollback变量中。
| |
分布式事务
MySQL数据库分布式事务
InnoDB提供了对XA事务的支持,并通过XA事务支持分布式事务的实现。
在使用分布式事务时,InnoDB存储引擎的事务隔离级别必须设置为SERIALIZABLE
XA事务允许不同的数据库之间的分布式事务
XA事务由一个或多个资源管理器(Resource Managers)、一个事务管理器(Transaction Manager)以及一个应用程序(Application Program)组成。
资源管理器:提供访问事务资源的方法。通常一个数据库就是一个资源管理器
事务管理器:协调参与全局事务中的各个事务。需要和参与全局事务的所有资源管理器进行通信
应用程序:定义事务的边界,指定全局事务中的操作 分布式事务使用两段式提交(two-phase commit)的方式:
所有参与全局事务的节点都开始准备(PREPARE)
事务管理器告诉资源管理器执行ROLLBACK还是COMMIT
不好的事务习惯
在循环中提交
使用自动提交
使用自动回滚
长事务
长事务(Long-Lived Transaction)。
可以分解成多个小事务
9.3 - Redis
Introduction
Redis
9.3.1 - Redis开发与运维
01 初始Redis
1.1 盛赞Redis
Redis中的值可以是由string、hash、list、set、zset、Bitmaps(位图)、HyperLogLog、GEO(地理信息定位)等多种数据结构和算法组成。
1.2 Redis特性
1、速度快
- 存储在内存中
- C语言编写,一般来说C语言编写的程序“距离”操作系统更近
- 单线程架构,预防了多线程可能产生的竞争
- 作者对源代码精打细磨(稍有的集性能和优雅于一身的开源代码)
2、基于键值对的数据结构服务器
3、丰富的功能
除了5中数据结构,还提供了许多额外功能:
- 键过期功能,可以实现缓存
- 发布订阅功能,可以实现消息系统
- 支持Lua脚本功能,可以利用Lua创造出新的Redis命令
- 简单的事务功能,能在一定程度上保证事务特性
- 流水线(Pipeline)功能,使客户端能将一批命令一次性传到Redis,减少网络开销
4、简单稳定
- 源码少(早期2万行,3.0版本以后增加集群,5万行)
- 单线程模型,服务端处理模型简单,客户端开发简单
- 不依赖与操作系统中类库,自己实现了事件处理的相关功能
5、客户端语言多
提供简单的TCP协议
6、持久化
两种持久化方式:RDB和AOF
7、主从复制
8、高可用和分布式
高可用实现Redis Sentinel保证Redis节点的故障发现和故障自动转移;分布式实现Redis Cluster
1.3 Redis使用场景
1.3.1 Redis可以做什么
- 缓存
- 排行榜系统
- 计数器应用
- 社交网络
- 消息队列系统
1.3.2 Redis不可以做什么
数据量大的、冷数据
1.4 用好Redis的建议
1.5 正确安装并启动Redis
1.6 Redis重大版本
借鉴Linux操作系统对于版本号的命名规则,版本号第二位如果是奇数,则为非稳定版本,偶数则为稳定版本
1、Redis 2.6
2012年正式发布
1)服务端支持Lua脚本
2)去掉虚拟内存相关功能
3)放开对客户端连接数的硬编码限制
4)键的过期时间支持毫秒
5)从节点提供只读功能
6)两个新的位图命令:bitcount和bitop
7)增强redis-benchmark的功能:支持定制化的压测,CSV输出等功能
8)基于浮点数自增命令:incrbyfloat和hincrbyfloat
9)redis-cli可以使用--eval参数实现Lua脚本执行
10)shutdown命令增强
11)info可以按照section输出,并且添加了一些统计项
12)重构了大量的核心代码,所有集群相关的代码都去掉了,cluster功能将会是3.0版本最大的亮点
13)sort命令优化
2、Redis 2.8
2013年11月22日正式发布
1)添加部分主从复制的功能,在一定程度上降低了由于网络问题,造成频繁全量复制生成RDB对系统造成的压力
2)尝试性地支持IPv6
3)可以通过config set命令设置maxclients
4)可以用bind命令绑定多个IP地址
5)Redis设置明显的进程名,方便使用ps命令查看系统进程
6)confi rewrite命令可以将config set持久化到Redis配置文件中
7)发布订阅添加了pubsub命令
8)Redis Sentinal第二版,相比于Redis 2.6的Redis Sentinel,此版本变成生产可用
3、Redis 3.0
2015年4月1日正式发布
1)Redis Cluster:Redis的官方分布式实现
2)全新的embedded string对象编码结果,优化小对象内存访问,在特定的工作负载下速度大幅提升
3)lru算法大幅提升
4)migrate链接缓存,大幅提升迁移的速度
5)migrate命令两个新的参数copy和replace
6)新的client pause命令,在指定时间内停止处理客户端请求
7)bitcount命令性能提升
8)confit set设置maxmemory时可以设置不同的单位(之前只能是字节),例如config set maxmemory 1gb
9)Redis日志小做调整:日志中会反应当前实例的角色(master或者slave)
10)incr命令性能提升
4、Redis 3.2
2016年5月6日正式发布
1)添加GEO相关功能
2)SDS在速度和节省空间上都做了优化
3)支持用upstart或者systemd管理Redis进程
4)新的List编码类型:quicklist
5)从节点读取过期数据保持一致性
6)添加hstrlen命令
7)增强了debug命令,支持了更多的参数
8)Lua脚本功能增强
9)添加了Lua Debugger
10)config set支持更多的配置参数
11)优化了Redis崩溃后的相关报告
12)新的RDB格式,但是仍然兼容旧的RDB
13)加速RDB的加载速度
14)spop命令支持个数参数
15)cluster nodes命令支持个数参数
16)Jemalloc更新到4.0.3版本
5、Redis 4.0
1)模块系统,方便第三方开发者扩展Redis功能
2)PSYNC 2.0:优化了之前版本中,主从节点切换必然引起全量复制的问题
3)提供了新的缓存提出算法:LFU(Last Frequently Used),并优化已有算法
4)提供了非阻塞del和flushall/flushdb功能,有效解决删除bigkey可能造成的Redis阻塞
5)提供了RDB-AOF混合持久化格式,充分利用了AOF和RDB各自优势
6)提供了memory命令,实现对内存更为全面的监控统计
7)提供了交互数据库功能,实现Redis内部数据库之间的数据置换
8)Redis Cluster兼容NAT和Docker
02 API的理解与使用
2.1 预备
2.1.1 全局命令
- key* 查看所有键
- dbsize 键总数
- exists key 检查键是否存在
- del key 删除键
- expire key seconds 键过期
- type key 键的数据结构类型
2.1.2 数据结构和内部编码
每种数据结构都有两种以上的内部编码实现,例如list包含linkedlist和ziplist,可使用object encoding key来查看
2.1.3 单线程架构
Redis使用单线程架构和I/O多路复用模型来实现高性能的内存数据库服务。
每次客户端调用都经历发送命令、执行命令、返回结果三个过程。
采用I/O多路复用技术来解决I/O的问题。
单线程快的原因:
- 纯内存访问(响应时长100纳秒)
- 非阻塞I/O(使用epoll作为I/O多路复用技术,加上Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费时间)
- 单线程避免了线程切换和竞态产生的消耗
2.2 字符串
值最大不超过512MB
2.2.1 命令
1、set key value
ex seconds:设置秒级过期时间
px milliseconds:设置毫秒级过期时间
nx: 键必须不存在,才可以设置成功,用于添加,分布式锁
xx:与nx相反,用于更新
mset key value [key value …]
2、get key
mget key [key …]
- incr key 计数
- append key value 追加字符串值
- strlen key 字符串长度
- getset key value
- setrange key offset value
- getrange key start end
2.2.2 内部编码
字符串的内部编码有3种:
- int:8个字节的长整形
- embstr:小于等于39个字节的字符创
- raw:大于39个字节的字符串
Redis会根据当前值的类型和长度决定使用哪种内部编码实现
2.2.3 典型使用场景
- 缓存功能
- 计数
- 共享session
- 限速(一分钟不超过x次)
2.3 哈希
2.3.1 命令
2.3.2 内部编码
哈希类型的内部编码有两种:
- ziplist(压缩列表):当元素个数小于hash-max-ziplist-entries(512)、同时所有值都小于hash-max-ziplist-vallue(64)时
- hashtable(哈希表):无法满足ziplist的条件
2.4 列表
2.4.1 命令
2.4.2 内部编码
- ziplist(压缩列表):同上
- linkedlist(链表):同
2.5 集合
2.5.2 内部编码
- intset(整数集合)
- hashtable(哈希表)
2.6 有序集合
2.6.2 内部编码
- ziplist(压缩列表)
- skiplist(跳跃表)
2.7 键管理
03 小功能大用处
3.1 慢查询分析
| |
3.2 Redis shell
| |
3.3 Pipeline
RTT(Round Trip Time,往返时间),节省网络传输时间,一次性执行多个命令
3.4 事务与Lua
3.4.3 Redis与Lua
eval和evalsha
04 客户端
4.1 客户端通信协议
4.2 Java客户端Jedis
4.3 Python客户端redis-py
| |
4.4 客户端管理
4.4.1 客户端API
| |
05 持久化
5.1 RDB
5.1.1 触发机制
| |
5.1.2 流程说明
bgsave是主流的触发RDB持久化方式,流程如下:
1)执行bgsave命令,Redis父进程判断当前是否存在正在执行的子进程,如RDB/AOF子进程,如果存在bgsave命令直接返回
2)父进程执行fork操作创建子进程,fork操作过程中父进程会阻塞,通过info stats命令查看latest_fork_usec选项,可以获取最近一个fork操作的耗时,单位为微秒
3)父进程fork完成后,bgsave命令返回“Background saving started”信息并不再阻塞父进程,可以继续响应其他命令
4)子进程创建RDB文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换。(执行lastsave命令可以获得最后一次生成RDB的时间,对应info统计的rdb_last_save_time选项
5)进程发送信号给父进程表示完成,父进程更新统计信息,具体见info Persistence下的rdb_*相关选项
5.1.3 RDB文件的处理
压缩:默认采用LZF算法对生成的RDB文件进行压缩处理,如果文件损坏,可以使用redis-check-dump检测获取错误报告
5.1.4 RDB的优缺点
优点:
- 紧凑压缩的二进制文件,非常适合用于备份,全量复制等场景
- Redis加载RDB恢复数据远远快于AOF的方式
缺点:
- 没有办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行fork操作创建子进程,属于重量级操作,频繁执行成本过高。
- 二进制文件保存,版本更替有多个格式,存在老版本服务无法兼容新版格式的问题
5.2 AOF(append only file)
追加命令到文件实现持久化
5.2.3 文件同步
5.2.4 重写机制
重写后的AOF文件变小的原因:
1)进程内已超时的数据不再写入文件
2)旧的AOF文件含有无效命令,只保留最终数据的写入命令
3)多条写命令可以合并为一个
重写过程可以手动触发和自动触发:
| |
06 复制
6.1配置
6.1.1 建立复制
配置复制的方式:
- 在配置文件中加入slaveof {masterHost} {masterPort}随Redis启动生效
- 在redis-server启动命令后加入—slaveof {masterHost} {masterPort}
- 直接使用命令:slaveof {masterHost} {masterPort}生效
6.2 拓扑
- 一主一从结构
- 一主多从结构
- 树状主从结构
6.3 原理
6.3.1 复制过程
- 保存主节点信息
- 从节点内部通过每秒运行的定时任务维护复制相关逻辑,当定时任务发现存在新的主节点后,会尝试与该节点建立网络连接
- 发送ping命令
- 权限验证
- 同步数据集
- 命令持续复制
6.3.2 数据同步
- 全量复制
- 部分复制
6.4 开发与运维中的问题
6.4.1 读写分离
当使用从节点响应读请求时,业务端可能会遇到如下问题:
1、数据延迟
2、读到过期数据
Redis内部需要维护过期数据删除策略:惰性删除和定时删除
3、从节点故障问题
6.4.2 主从配置不一致
6.4.3 规避全量复制
6.4.4 规避复制风暴
07 Redis的噩梦:阻塞
7.1 发现阻塞
在实现异常统计时要注意,由于Redis调用API会分散在项目的多个地方,每个地方都监听异常并加入监控代码必然难以维护。可以借助日志系统!(RedisAppender)
7.2 内在原因
7.2.1 API或数据结构使用不合理
1、如何发现慢查询
slowlog get {n} # 获取最近的n条慢查询命令
调整方向:1)修改为低算法度的命令,如hgetall改为hmget等,禁用keys、sort等命令;2)调整大对象,拆分为小对象,防止一次命令操作过多的数据
2、如何发现大对象
redis-cli -h {ip} -p {port} -—bigkeys
redis-cli —-bigkeys
7.2.2 CPU饱和
7.2.3 持久化相关的阻塞
1、fork阻塞
2、AOF刷盘阻塞
3、HugePage写操作阻塞
7.3 外在原因
7.3.1 CPU竞争
7.3.2 内存交换
7.3.3 网络问题
1、连接拒绝(网络闪断、Redis连接拒绝、连接溢出)
2、网络延迟
3、网卡软中断
08 理解内存
8.1 内存消耗
8.1.1 内存使用统计
| |
8.1.2 内存消耗划分
Redis进程内消耗主要包括:自身内存+对象内存+缓冲内存+内存碎片
1、对象内存
可以简单理解为sizeof(keys) + sizeof(values)
2、缓冲内存
主要包括:客户端缓冲、复制挤压缓冲区、AOF缓冲区
3、内存碎片
8.1.3 子进程内存消耗
8.2 内存管理
8.2.1 设置内存上限
8.2.2 动态调整内存上限
8.2.3 内存回收策略
1、删除过期键对象
- 惰性删除
- 定时任务删除
2、内存溢出控制策略
8.3 内存优化
8.3.1 redisObject对象
Redis存储的所有值对象在内部定义为redisObject结构体
1、type:表示当前对象使用的数据类型,如string、hash、list、set、zset等,可以使用 type {key}查看对象所属类型
2、encoding:表示Redis内部编码类型
3、lru:记录对象最后一次被访问的时间
4、refcount:记录当前对象被引用的次数,用于引用次数回收内存,当为0时可以安全回收当前对象空间
5、*ptr:与对象的数据内容相关,如果是整数,直接存储数据;否则指向数据的指针
8.3.2 缩减键值对象
降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。序列化工具,如protostuff、kryo等
8.3.3 共享对象池
指Redis内部维护[0-9999]的整数对象池。每个redisObject内部结构至少占16字节,用于节约内存
问:为什么开启maxmemory和LRU淘汰策略后对象池无效?
答:LRU算法需要获取对象最后被访问时间(lru字段),对象共享意味着多个引用共享同一个redisObject,这是lru字段也会被共享,导致无法获取每个对象的最后访问时间。如果没有设置maxmemory,直到内存被用尽Redis也不回触发内存回收,所以共享对象池可以正常工作。综述:共享对象池与maxmemory+LRU策略冲突,使用时需要注意。
问:为什么只有整数对象池?
答:因为整数比较算法时间复杂度为O(1),只保留一万个整数为了防止对象池浪费。字符串判断相等性,时间复杂度O(n),特别是长字符串更消耗性能(浮点数在Redis内部使用字符串存储)。更复杂的数据结构如hash、list等,相等性判断需要O(n*n)。
8.3.4 字符串优化
1、字符串结构
Redis没有采用原生C语言的字符串类型而是自己实现了字符串结构,内部简单动态字符串(simple dynamic string, SDS)。
Redis自身实现的字符串结构特点:
- O(1)时间复杂度获取:字符串长度、已用长度、未用长度
- 可用于保存字节数组,支持安全的二进制数据存储
- 内部实现空间预分配机制,降低内存再分配次数
- 惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留
2、预分配机制
3、字符串重构
8.3.5 编码优化
1、了解编码
2、控制编码类型
3、ziplist编码
4、intset编码
8.3.6 控制键的数量
09 哨兵(Redis Sentinel)
9.1 基本概念
9.1.1 主从复制的问题
9.1.2 高可用
9.1.3 Redis Sentinel的高可用性
注意:Redis 2.6(Redis Sentinel v1)功能性和健壮性都有一些问题,建议使用Redis 2.8(Redis Sentinel v2)以上。
Redis Sentinel功能:
- 监控
- 通知
- 主节点故障转移
- 配置提供者
- 节点的故障判断由多个Sentinel节点共同完成,防止误判
- Sentinel节点集合由若干个Sentinel节点组成,即使个别不可用,整个节点集合依然是健壮的 9.2 安装和部署
9.2.1 部署拓扑结构
9.2.2 部署Redis数据节点
9.2.3 部署Sentinel节点
9.2.4 配置优化
9.2.5 部署技巧
9.3 API
9.4 客户端连接
9.4.1 Redis Sentinel的客户端
9.4.2 Redis Sentinel客户端基本实现原理
9.4.3 Java操作Redis Sentinel
9.5 实现原理
9.5.1 三个定时监控任务
9.5.2 主观下线和客观下线
9.5.3 领导者Sentinel节点选举
9.5.4 故障转移
9.6 开发与运维中的问题
9.6.1 故障转移日志分析
9.6.2 节点运维
9.6.3 高可用读写分离
10 集群
之前Redis分布式方案一般有两种:
- 客户端分区方案(优点:分区逻辑可控,缺点:自己处理数据路由、高可用、故障转移等问题)
- 代理方案(优点:简化客户端分布式逻辑和升级维护遍历,缺点:加重架构部署复杂度和性能损耗)
官方提供的集群方案:Redis Cluster,优雅地解决了Redis集群方面的问题。
10.1 数据分布
10.1.1 数据分布理论
常见的分区规则有哈希分区和顺序分区两种。Redis Cluster采用哈希分区规则。常见的分区规则:
1、节点取余分区
2、一致性哈希分区
优点:加入或删除节点只影响哈希环中相邻的节点,对其它节点无影响。
问题:
- 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用语缓存场景
- 当使用少量节点时,节点变化将大范围影响哈希环中的数据映射,因此这种方式不适合少量数据节点的分布式方案
- 普通的一致性哈希分区在增减分区节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡 3、虚拟槽分区
虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有的数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数,比如Redis Cluster槽范围是0~16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据才分和集群扩展。
10.1.2 Redis数据分区
Redis虚拟槽分区的特点:
- 解耦数据和节点之间的关系,简化了节点扩容和收缩难度
- 节点自身维护槽的映射关系,不需要客户或者代理服务维护槽分区元数据
- 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景
10.1.3 集群功能限制
Redis集群相对单机在功能上存在一些限制:
- key批量操作支持有限。如mset、mget,目前只支持具有相同slot值得key执行批量操作。对于映射为不同slot值得key由于执行mset、mget等操作可能存在于多个节点因此不被支持
- key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能
- key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点
- 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db 0
- 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构
10.2 搭建集群
步骤:1、准备节点;2、节点握手;3、分配槽
10.2.1 准备节点
10.2.2 节点握手
10.2.3 分配槽
10.2.4 用redis-trib.rb搭建集群
redis-trib.rb是采用Ruby实现的Redis集群管理工具。内部通过Cluster相关命令帮我们简化集群创建、检查、槽迁移和均衡等常见运维操作,使用之前需要安装Ruby依赖环境。
10.3 节点通信
10.3.1 通信流程
10.3.2 Gossip通信
10.3.3 节点选择
10.4 集群伸缩
10.4.1 伸缩原理
10.4.2 扩容集群
10.4.3 收缩集群
10.5 请求路由
10.5.1 请求量定向
10.5.2 Smart客户端
10.6 故障转移
10.6.1 故障发现
故障发现通过消息传播机制实现,主要环节包括:主管下线(pfail)和客观下线(fail)
- 主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
- 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。 10.6.2 故障恢复
10.6.3 故障转移时间
10.6.4 故障转移演练
10.7 集群运维
10.7.1 集群完整性
10.7.2 带宽消耗
10.7.3 Pub/Sub广播问题
10.7.4 集群倾斜
10.7.5 集群读写分离
10.7.6 手动故障转移
10.7.7 数据迁移
11 缓存设计
11.1 缓存的收益和成本
收益:
- 加速读写
- 降低后端复杂
成本:
- 数据不一致性:缓存层和数据层的数据存在着一定时间窗口的不一致性,时间窗口跟更新策略有关
- 代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本
- 运维成本:以Redis Cluster为例,加入后无形中增加了运维成本
缓存的使用场景包括如下两种:
- 开销大的复杂计算
- 加速请求响应
11.2 缓存更新策略
1、LRU/LFU/FIFO算法剔除
2、超时剔除
3、主动更新
4、最佳实践
| 策略 | 一致性 | 维护成本 |
|---|---|---|
| LRU/LRF/FIFO算法剔除 | 最差 | 低 |
| 超时剔除 | 较差 | 较低 |
| 主动更新 | 强 | 高 |
11.3 缓存粒度控制
缓存全部属性还是缓存部分重要属性?从以下3个角度进行说明
1、通用性
缓存全部数据比部分数据更加通用,但从实际经验看,很长时间内应用只需要几个重要的属性
2、空间占用
缓存全部数据占用更多的空间,可能存在以下问题:1)内存的浪费;2)全部数据每次传输产生的网络流量会比较大,耗时相对较大,在极端情况下回阻塞网络;3)全部数据的序列化和反序列化的CPU开销大
3、代码维护
全部数据的优势更加明显,而部分数据一旦要加新字段需要修改业务代码,而且修改后通常还需要刷新缓存数据
11.4 穿透优化
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层。步骤如下:
- 缓存层不命中
- 存储层不命中,不将结果写回缓存
- 返回空结果 解决缓存穿透问题:
1、缓存空对象
问题:1)空值做了缓存,意味着缓存层存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。2)缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。列如设置过期时间为5分钟,此时存储层添加了这个数据,那么会出现不一致情况,可以利用消息系统或其他方式清除掉缓存层中的空对象。
2、布隆过滤器拦截
在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截。
这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。
2种方案对比
| 解决缓存穿透 | 适用场景 | 维护成本 |
|---|---|---|
| 缓存空对象 | 1、数据命中不高 2、数据频繁变化实时性高 | 1、代码维护简单 2、需要过多的缓存空间 3、数据不一致 |
| 布隆过滤器 | 1、数据命中不高 2、数据相对固定实时性低 | 1、代码维护复杂 2、缓存空间占用少 |
11.5 无底洞优化
“无底洞”现象:添加大量新Memcache节点,但是性能不但没有变好反而下降。
无底洞现象分析:
- 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多、耗时会不断增大。
- 网络连接数变多,对节点的性能也有一定影响。
常见的IO优化思路:
- 命令本身的优化,例如优化SQL语句等
- 减少网络通信次数
- 降低接入成本,例如客户端使用长连/连接池、NIO等
4种分布式批量操作方案对比:
| 方案 | 优点 | 缺点 | 网络IO |
|---|---|---|---|
| 串行命令 | 1)编程简单 2)如果少量keys,性能可以满足要求 | 大量keys请求延迟严重 | O(keys) |
| 串行IO | 1)编程简单 2)少量节点,性能满足要求 | 大量node延迟严重 | O(nodes) |
| 并行IO | 利用并行特性,延迟取决于最慢的节点 | 1)编程复杂 2)由于多线程,问题定威可能较差 | O(max_slow(nodes)) |
| hash_tag | 性能最高 | 1)业务维护成本较高 2)容易出现数据倾斜 | O(1) |
11.6 雪崩优化
如果缓存层由于某些原因不能提供服务,所有的请求都会打到存储层呢刚,存储层的调用量会暴增,造成存储层也会级联宕机的情况。缓存雪崩(stampeding herd,奔逃的野牛)指的是缓存层宕掉后,流量就会像奔逃的野牛一样,打到后端存储。
预防和解决缓存雪崩问题,可以从以下三个方面进行着手。
1)保证缓存层服务高可用性。
2)依赖隔离组件为后端限流并降级。(Java依赖隔离工具:Hystrix)
3)提前演练
11.7 热点key重建优化
使用“缓存+过期时间”的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足大部分需。但是如果同时出现以下2个问题,会对应用造成致命的伤害:
- 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大
- 重建缓存不能再短时间完成,可能是一个复杂计算,例如复杂的SQL、多次IO、多个依赖等
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:
- 减少重建缓存的次数
- 数据尽可能一致
- 较少的潜在危险
1、互斥锁(mutex key)
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
2、永远不过期
包括两层意思:
- 没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
- 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
| 解决方案 | 优点 | 缺点 |
|---|---|---|
| 简单分布式锁 | 1、思路简单 2、保证一致性 | 1、代码复杂度增大 2、存在死锁的风险 3、存在线程池阻塞的风险 |
| “永远不过期” | 基本杜绝热点key问题 | 1、不保证一致性 2、逻辑过期时间增加代码维护成本和内存成本 |
12 开发运维的“陷进”
12.1 Linux配置优化
12.1.1 内存分配控制
1、vm.overcommit_memory
overcommit:Linux操作系统对大部分申请内存的请求都回复yes,以便能运行更多的程序。因为申请内存后,并不会马上使用内存,这种技术叫做overcommit。
2、获取和设置
| |
3、最佳实践
- Redis设置合理的maxmemory,保证机器有20%~30%的闲置内存
- 集中化管理AOF重写和RDB的bgsave
- 设置vm.overcommit_memory=1,防止极端情况下会造成fork失败
12.1.2 swappiness
1、参数说明
swap:当物理内存不足时,可以将一部分内存进行swap操作,swap空间由硬盘提供,对于需要高并发、高吞吐的应用来说,磁盘IO通常成为系统瓶颈。在Linux中,并不是要等到所有物理内存都是用完才会使用swap,系统参数swappiness会决定操作系统使用swap的倾向程度。swappiness的取值范围是0~100,swappiness的值越大,说明操作系统可能使用swap的概率越高,swappiness值越低,表示操作系统更加倾向于使用物理内存。swap的默认值是60。
swappiness重要值策略说明:
| 值 | 策略 |
|---|---|
| 0 | Linux3.5以及以上:宁愿用OOM killer也不用swap |
| 1 | Linux3.4以及以上:宁愿用swap也不用OOM killer |
| 60 | 默认值 |
| 100 | 操作系统会主动地使用swap |
OOM(Out of Memory) killer机制是指Linux操作系统发现可用内存不足时,强制杀死一些用户进程(非内核进程),来保证系统有足够的可用内存进行分配。
2、设置方法
echo {bestvalue} > /proc/sys/vm/swappiness # 设置操作 # 重启系统后就会失效
echo vm.swappiness={bestvalue} >> /etc/sysctl.conf #追加操作
3、如何监控swap
(1)查看swap的总体情况
free命令查询操作系统的内存使用情况
| |
(2)实时查看swap的使用
vmstat命令查询系统的相关性能指标,其中包括负载、CPU、内存、swap、IO的相关属性。但其中和swap有关的指标是si和so,分别代表操作系统的swap in和swap out。
| |
(3)查看指定进程的swap使用情况
/proc/{pid}目录是存储指定进程的相关信息,其中/proc/{pid}/smaps记录了当前进程所对应的内存映像信息,这个信息对于查询指定进程的swap使用情况很有帮助。
| |
12.1.3 THP
Transparent Huge Pages(THP):Linux kernel在2.6.38内核增加了THP特性,支持大内存页(2MB)分配,默认开启。当开启时可以加快fork子进程的速度,但fork操作之后,每个内存页从原来4KB变为2MB,会大幅增加重写期间父进程内存消耗。同时每次写命令引起的复制内存页单位放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询,例如简单的incr命令也会出现在慢查询中。因此Redis日志中建议将此特性进行禁用,方法如下:
| |
12.1.4 OOM killer
OOM killer会在可用内存不足时选择性地杀掉用户进程。运行规则:OOM killer进程会为每个用户进程设置一个权值,这个权值越高,被“下手”的概率就越高,反之概率越低。每个进程的权值存放在/proc/{progress_id}/oom_score中,这个值受/proc/{progress_id}/oom_adj的控制,oom_adj在不同的Linux版本中最小值不同。当oom_adj设置为最小值时,该进程将不会被OOM killer杀掉,设置方法如下。
| |
对于Redis所在的服务器来说,可以将所有Redis的oom_adj设置为最低值或者稍小的值,降低被OOM killer杀掉的概率。
| |
12.1.5 使用NTP
NTP(Network Time Protocol,网络时间协议)是一种保证不同机器时钟一致性的服务。
例如每小时的同步1次NTP服务:
| |
12.1.6 ulimit
在Linux中,可以通过ulimit查看和设置系统当前用户进程的资源数。其中ulimit -a命令包含的open files参数,是单个用户同时打开的最大文件个数:
| |
12.1.7 TCP backlog
Redis默认的tcp-backlog值为511,可以通过修改配置tcp-backlog进行调整。
查看方法:
| |
修改方法:
| |
12.2 flushall/flushdb误操作
Redis的flushall/flushdb命令可以做数据清除。
12.2.1 缓存与存储
Redis可以做缓存或者存储,被误操作flush后,使用策略有所不同。
12.2.2 借助AOF机制恢复
执行flush之后,提示如下所示:
(1)appendonly no:对AOF持久化没有任何影响,因为不存在AOF文件
(2)appendonly yes:只不过在AOF文件中追加了一条记录。如:
| |
虽然Redis中的数据被清除掉了,但是AOF文件还保存着flush操作之前完整的数据,对恢复数据还是很有帮助的,但注意问题如下:
1)如果发生了AOF重写,Redis遍历所有数据库重新生成AOF文件,并会覆盖之前的AOF文件。所有如果AOF重写发生了,也就意味着之前的数据就丢掉了,那么利用AOF文件来恢复的办法就失效了。所以当误操作后,需要考虑如下两件事:
- 调大AOF重写参数auto-aof-rewrite-percentage和auto-aof-rewrite-min-size,让Redis不能产生AOF自动重写。
- 拒绝手动bgrewriteaof
2)如果要用AOF文件进行数据恢复,那么必须要将AOF文件中的flushall相关操作去掉,为了更加安全,可以在去掉之后使用redis-check-aof这个工具去检验和修复一下AOF文件,确保AOF文件格式正确,保证数据恢复正常。
12.2.3 RDB有什么变化
Redis执行了flushall操作后,RDB持久化文件会受到什么影响呢?
1)如果没有开启RDB的自动策略,也就是配置文件中没有如下类似配置:
| |
那么除非手动执行过save、bgsave或者发生了主从的全量复制,否则RDB文件也会保存flush操作之前的数据,可以作为恢复数据的数据源。注意问题如下:
- 防止手动执行save、bgsave,如果此时执行save、bgsave,新的RDB文件就不会包含flush操作之前的数据,被老的RDB文件进行覆盖。
- RDB文件中的数据可能没有AOF实时性高,也就是说,RDB文件很可能很久之前主从全量复制生成的,或者之前用save、bgsave备份的。
2)如果开启了RDB的自动策略,由于flush涉及键值数量较多,RDB文件会被清除,意味着使用RDB恢复基本无望。
综上,如果AOF已经开启,那么用AOF来恢复比较合理,如果AOF关闭,那么RDB虽然数据不是很实时,但是也能恢复部分数据,完全取决于RDB是什么时候备份的。(RDB的恢复速度比AOF快很多,但总体来说对于flush操作之后不是最好的恢复数据源)
12.2.4 从节点有什么变化
Redis从节点同步了主节点的flush命令,所以从节点的数据也是被清除了,从节点的RDB与AOF的变化与主节点没有任何区别。
12.2.5 快速恢复数据
下面使用AOF作为数据源进行恢复演练
1)防止AOF重写。快速修改Redis主从的auto-aof-rewrite-percentage和auto-aof-rewrite-min-size变为一个很大的值,从而防止了AOF重写的发生。
| |
2)去掉主从AOF文件中的flush相关内容
| |
3)重启Redis主节点服务器,恢复数据
12.3 安全的Redis
被攻击Redis的特点:
- Redis所在机器有外网IP
- Redis以默认端口6379为启动端口,并且是对外网开放的
- Redis是以root用户启动的
- Redis没有设置密码
- Redis的bind设置为0.0.0.0或者“”
12.3.1 Redis密码机制
12.3.2 伪装危险命令
1、引入rename-command
2、没有免费的午餐
- 管理员有一定的开发和维护成本,都需要使用重命名之后的命令
- rename-command配置不支持config set,所以在启动前一定要确定哪些命令需要使用rename-command
- 如果AOF和RDB文件包含了rename-command之前的命令,Redis将无法启动,因为此时它识别不了rename-command之前的命令
- Redis源码中一些命令是写死的,rename-command可能造成Redis无法正常工作,例如config命令。
3、最佳实践
- 对于危险的命令,无论内网外网,一律使用rename-command配置
- 建议第一次配置Redis时,就应该配置rename-command,因为rename-command不支持config set
- 如果涉及主从关系,一定要保持主从节点配置的一致性,否则存在主从数据不一致的可能性
12.3.3 防火墙
限制输入和输出的IP或者IP范围、端口或者端口范围。(必杀技)
12.3.4 bind
1、对于bind的错误认识
bind指定的是Redis和哪个网卡进行绑定,和客户端是什么网段没有关系。
2、建议
- 如果机器有外网IP,但部署的Redis是给内部使用,建议去掉外网网卡或者使用bind配置限制流量从外网进入
- 如果客户端和Redis部署在一台机器上,可以使用回环地址
- bind配置不支持config set,所以尽可能在第一次启动前配置好
12.3.5 定期备份数据
12.3.6 不适用默认端口
Redis的默认端口是6379
12.3.7 使用非root用户启动
12.4 处理bigkey
12.4.1 bigkey的危害
- 内存空间不均匀
- 超时阻塞
- 网络拥塞
12.4.2 如何发现
| |
在实际生产环境中发现bigkey的两种方式如下:
- 被动收集(开发人员日志key)
- 主动监测
12.4.3 如何删除
12.5 寻找热点key
1、客户端
使用map<key, count>记录,如在connection类中的sendCommand方法是所有命令执行的枢纽
- 无法预知key的个数
- 对于客户端代码有侵入
- 只能了解当前客户端的热点key,无法实现规模化运维统计
除了使用本地字典计数外,还可以使用其他存储来完成异步计数
2、代理端
3、Redis代理端
4、机器
可以通过对机器上所有Redis端口的TCP数据包进行抓取完成热点key的统计
寻找热点key的四种方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 客户端 | 实现简单 | 1、内存泄露隐患 2、维护成本高 3、只能统计单个客户端 |
| 代理 | 代理是客户端和服务端的桥梁,实现最方便最系统 | 增加代理端的开发部署成本 |
| 服务端 | 实现简单 | 1、Monitor本身的使用成本和危害,只能短时间使用 2、只能统计单个Redis节点 |
| 机器 | 对于客户端和服务端无侵入和影响 | 需要专业的运维团队开发,并且增加了机器的部署成本 |
13 Redis监控运维云平台CacheCloud
13.1 CacheCloud是什么
13.2 快速部署
13.3 机器部署
13.4 接入应用
13.5 用户功能
13.6 运维功能
13.7 客户端上报
2020年01月11日(周六)完
9.4 - TiDB
Introduction
TiDB
9.4.1 - TiDB初探
TiDB简介
TiDB 是一个开源的、兼容 MySQL、可以横向扩展的、可以完美替代分库分表的原生分布式关系型数据库,且支持 HTAP。2015 年在 GitHub 开源立项。

TiDB架构
主要模块
TiDB 架构主要分为四个模块:
- TiDB Server
- TiKV Server
- TiSpark
- PD(Placement Drive)Server TiKV Server:负责数据存储,一个分布式的提供事务的Key-Value存储引擎,维护多副本(默认三副本),支持高可用和自动故障转移。
PD Server:整个 TiDB 集群的元信息管理模块,负责存储每个 TiKV 节点实时的数据分布情况和集群的整体拓扑结构,提供 TiDB Dashboard 管控界面,并未分布式事务分配事务 ID。还会根据 TiKV 节点实时上报数据分布状态,下发数据调度命令给具体的 TiKV 节点(如热点 region 调度),可以说是整个集群的“大脑”。
TiDB Server:SQL 层,对外暴露 MySQL 协议连接 endpoint,负责接受客户端的连接,执行 SQL 解析和优化,最终生成分布式执行计划,也支持所有 SQL 算子实现。TiDB 层本身无状态,内部可提供多个实例,通过负载均衡组件(如 LVS、HAProxy、F5)对外提供统一的接入。
TiFlash:用来做重型 IP 的 SQL 或者作业查询,做一些分布式计算。
整体架构

TiKV
概述
本文依次从下向上按照数据结构、表模型、分片策略、复制、多版本控制、分布式事务等维度的介绍 TiKV 的选择与平衡。
数据结构
传统的 OLTP 系统中,写操作是最昂贵的成本。
- 传统的 B-tree 索引至少要写两次数据:预写日志(WAL)和树本身。
- B-tree是一个严格平衡的数据结构,整体设计对读友好。数据写入触发的 B-tree 分裂和平衡的成本非常高,对写相对不够友好。
- 传统的主从架构中,集群的写入容量无法扩展,集群的写入容量完全由主库的机器配置决定,扩容只能通过非常昂贵的集群拆分,即分库来实现。 LSM-tree 结构本质上是一个用空间置换写入延迟,用顺序写入替换随机写入的数据结构。
TiKV节点选择了基于 LSM-tree 的 RocksDB 引擎。其支持很多特性:
- 批量写入(事务)
- 无锁的快照读(数据副本迁移)
数据副本与复制
数据冗余决定了系统的可用性,如何选择复制协议尤为重要。
Raft 是一种用于替代 Paxos 的共识算法。相比 Paxos,Raft 的目标是提供更清晰的逻辑分工使得算法本身能被更好地理解,同时它的安全性更好,并能提供一些额外的特性。
Raft 算法通过先选出 leader 节点、有序日志等方式简化流程、提高效率,并通过约束减少了不确定性的状态空间。相对 Paxos 它的逻辑更加清晰、容易理解以及工程化实现。
所以利用 Raft 可以基于 RocksDB 构建一个多副本的集群。
分片
数据分片是分布式数据里的关键设计,从底层技术来看实现扩展就是要做分片。分片分为:
预先分片(静态分片)
自动分片(动态分片) 创传统的分库分表或者分区的方案都是预先分片。这种分片只解决了表的容量问题,没有解决更细粒度的弹性问题。所以,第一要实现扩展要使用自动分片的算法,其次分片需要一个维度和算法。常见的分片算法有:
哈希(hash)
范围(range)
列举(list) TiKV 使用了 Range 算法,原因如下:
更高效的扫描数据记录
支持范围查询,如 >= 的查询。Range 分片可以更高效的扫描数据行数。而使用另外两种算法,由于数据被打散,扫描操作的 I/O 成本会更跳跃、开销会更大。
- 简单实现自动完成分裂与合并
对弹性本身比较重要
- 弹性优先,分片可以自由调度 Range 分片的问题是热点分片问题(最新的分片往往最热)。一般通过再分片来解决热点问题。
分离与扩展
当 Region 大小超过一定限制(默认144MB),TiKV 会将它分裂为两个或更多个 Region,以保证各个Region大小大致接近,这样有利于 PD(Placement Driver)进行调度决策。反之亦然,两个相邻的比较小的Region会自动合并。
调度机制
- 分片数量、Leader、吞吐量自动平衡
- 自定义调度接口
- 支持跨 IDC 表级同时写入

- 支持跨 IDC 表级同时写入
TiKV整体架构

多版本控制(MVCC)
TiKV 的 MVCC 实现是通过在 Key 后面添加版本号来实现。
分布式事务模型
- 去中心化的两阶段提交
- 每个 TiKV 节点分配单独区域存放锁信息(CF lock)
- 通过 PD 全局授时(TSO)
- ~4M timestamps 每秒
- Google Percolator 事务模型
- TiKV 支持完整事务 KV API
- 默认乐观事务模型
- 支持悲观事务模型(3.0+版本)
- 默认隔离级别 Snapshot Isolation(SI,和RR接近,没有幻读) 传统的两阶段提交需要一个事务管理器 GTM,其往往会成为整个集群的性能瓶颈,而 TiKV 采取了一个去中心化的两阶段提交。在每个 TiKV 存储节点上,会单独分配一个存储锁信息的地方。TiKV 的锁是基于列簇,锁信息称为 CF Lock。通过该机制将锁信息放在不同的存储节点,而不是像传统的数据库将锁信息放在行上的方式。然后通过 PD 全局授时。
协作处理器(Coprocessor)

整体结构:

SQL引擎
基于KV实现逻辑表

对于已经有的全局有序的分布式的 Key-Value 的存储引擎。
- 对于快速获取一个数据,找到具体的Key(主键),能够通过 TiKV 提供的一个 Seek 方法快速定位这一行数据所在的位置,找到 Value(其它列的数据)。
- 对于扫描全表的需求,如果能映射为一个 Key 的范围,可以从 StartKey 扫描到 EndKey,通过该方式获取全表数据。类似 Index 的思路。 TiKV的实现:
TiKV对于每个表分配一个 TableID,每个索引分配一个 IndexID,每行数据分配一个 RowID,如果表示有整数的 Primary Key(主键),可以把 RowID + IndexID 简单看做 Key,Value 看成所有的列按照等位偏移的方式进行 connect 进行连接。数据查询过程中通过等位偏移量进行对 Value 进行反解析,然后再对应与 Schema 的元信息进行列信息映射。
基于KV的二级索引设计
TiKV 中,二级索引也是一个全局有序的 Key-Value map,简单理解为 Key 就是索引的列信息,Value 是原表的 Primary key 主键,通过该主键在原表的 Key-Value map 进行再一次扫描找到 Value,然后再按照等位偏移量进行列信息解析。该过程和传统数据库 B-tree 的回表逻辑类似。

SQL引擎过程
SQL 引擎层:

SQL 引擎很重要的模块就是优化器,负责从很多执行计划中找到最优的执行计划。简单理解为车子出行中的交通工具如:数据寻址或者数据计算的各种算子,比如常见的 hash join、Index reader、Table Scan 等。路况:表、索引、列的数据分布统计信息等。
除了优化器,还有如下必须的组成部分,如 SQL 引擎过程:
- SQL
- 词法解析
- 语法解析
- 语义解析
- 权限控制等
- AST(抽象语法树)
- 将 SQL 从文本解析成一个结构化的数据,生成 AST 文件
- Logical Plan
- SQL 逻辑部分将各种 SQL 等价改写以及优化,如将子查询改成表关联、各种不必要的信息裁剪(列裁剪、分区裁剪、left join 裁剪等)
- Optimized Logical Plan
- 物理优化会基于统计信息与成本进行生产执行计划
SQL优化中最重要、优化空间最大的部分
- 执行器
- 执行引擎与根据优化器定下来的执行路径进行相应的数据的寻址、数据的计算

- 执行引擎与根据优化器定下来的执行路径进行相应的数据的寻址、数据的计算
关键算子
基于成本优化器
- Power CBO Optimizer
- Hash join、Sort merge、Index join、Apply(Nested loop)
- table_reader、table_scan、index_reader、index_scan、index_lookup
- Steam aggregation、Hash agg
- Cost
- Cost(p) = N(p)*FN+M(p)*FM+C(p)*FC, N stands for the network cost, M stands for the memory cost and C stands for the CPU cost.
- task ( handle on TiDB or TiKV )
- corp、root

- corp、root
分布式SQL引擎主要优化策略
最大程度让数据在分布式存储层尽快的完成过滤以及计算,即最大下推策略(Push Down)。
用户表在不同存储节点的分片进行预计算,完成本地的数据过滤以及统计,然后再将本地存储节点的临时结果、中间结果上报到 Server 层进行再一次 SUM 返回最终结果,利用了分布式多节点的计算能力。而不是上传到 Server 端进行统一的过滤及计算,没有利用 TiKV 的并行能力。

关键算子分布式化
TiDB-Server 中的 Hash Join 不管是在数据寻址,还是在内层进行分批匹配,都可以通过并行与分批的处理。这也是在大表 Join 的场景,比传统的 MySQL 的 join 的场景要快很多的原因。

Online DDL算法
- TiDB 没有分表概念,整个 DDL 完成过程非常快速
- Schema(表结构)只存储一份,新增字段时,新增数据按照新的Schema进行存储,老数据只需要在读到(默认值)和变更时,才需要进行新 Schema 的重组。
- 保证多个计算节点的Schema信息一致:根据 Google 的 F1 论文算法,将 Schema 变更异步分成了多个版本,把 DDL 过程分成 Public、Delete-only、Write-only 等几个相邻状态,每个相邻状态在多节点之间互相同步和一致,最终完成完整的 DDL。

TiDB-Server
TiDB-Server 是一个对等、无状态的,可横向扩展,支持多点写入,直接承接用户 SQL 入口。
连接到 TiDB-Server:

从进程角度看 TiDB-Server

从内部结构看 TiDB-Server

其它功能
前台功能:
管理链接和账号权限管理
MySQL 协议编码解码
独立的 SQL 执行
库表元信息以及系统变量 后台功能:
垃圾回收(GC)
执行 DDL
统计信息管理
SQL 优化器与执行器
TiDB与TiKV关系

分布式HTAP数据库
- TiDB 是一款支撑 HTAP 数据服务的数据库
- 理解 TiDB 在 HTAP 场景下的体系架构与产品迭代
- 了解 HTAP 应用场景
HATP 发展的必然性
HTAP 数据库需要同时支持 OLTP 和 OLAP 场景。基于创建的计算存储框架,在同一份数据上保证了事物的同时又支持实时分析,省去了费时的 ETL 过程。
在线分析事务(OLAP)相关技术:并行计算、物化视图、列存、Partition、Bitmap、索引等。
数据技术驱动的两个关键性因素:
- 数据容量
- 业务创建导致的场景多样性 在数据容量爆发性的前提下,OLTP 与 OLTP 技术开始分道扬镳,OLTP 业务更加追求吞吐的高并发、低延迟(小汽车:灵活、快速),OLAP 业务更加关注整个数据的吞吐量(大轮船:装载量和吞吐量)。因此形成了狭义的数据和大数据两个方向。

而分布式技术的发展,逐步解决了数据容量爆炸的问题,分布式关系型数据库,同时满足了 OLTP 的需求,也解决了数据容量的问题。在此基础上,很多传统的 OLAP 技术可以在此架构上进行再融合,实现了更大数据容量的混合数据库,也就是 HTAP。同时业务创新的场景多样性,在使用层面开始模糊了 OLTP 和 OLAP 的划分,比如业财一体、后台运营、客服后台、大屏展示、报表系统。从该角度,HTAP 又是一个数据服务的需求,其核心诉求是数据服务的统一。
TiDB用于数据中台
- 海量存储允许多数据源汇聚,数据实时同步
- 支持标准SQL,多表关联快速出结果
- 透明多业务模块、支持分表聚合后可以任务维度查询
- TiDB 最大下推机制、以及并行 hash join 等算子,决定 TiDB 在表关联上的优势 这些特性适用于后台运营系统、财务报表、大屏展现、用户画像等数据中台的一些业务。
引入spark缓解数据中台算力
TiDB-Server 虽然有上面说到的诸多特性,但其还是主要面向 OLTP 的业务,对于 OLAP 中间结果过大的查询还会造成内存使用过量,甚至 OOM 的问题。为了满足用户的需求,借助社区的力量,引入了大数据技术 Spark 的生态, 让 Spark 识别 TiKV 的数据格式、统计信息、索引、执行器,最终构建了一个能跑在 TiKV 上的 Spark 的计算引擎,并封装为 TiSpark。
进而实现了一个分布式的技术平台,在面对大批量数据的报表和重量级的 Adhoc 里提供了一个可行的方案。
Spark 只能提供低并发的重量级查询,在应用场景,很多中小规模的轻量 AP 查询,也需要高并发、相对技术低延迟计数能力,该场景下,Spark 的技术模型重,资源消耗高的缺点就会暴露。
物理隔离是最好的资源隔离
OLTP 和 OLAP 的资源隔离很难通过软件层面彻底解决好,从数据库资源隔离的角度看,依次是数据库软件层、副本调度、容器、虚拟机、物理机等,越接近物理机的隔离性会越好。
在传统的主从架构下,读写分离其实也是资源隔离的问题。隔离就需要有一个单独的副本进行 AP 的查询。列存天然对 OLAP 查询类友好,所以选择将这个副本放到一个列式存储引擎上。列式存储引擎需要按照列的单位进行存储,每个列是一个独立的对象。这种引擎对批量写入友好,最大的挑战在于对实时跟新不友好。

借助列式引擎的思想,引入了 Delta tree 的方法,最终实现了一个支持准实时更新的列式引擎 TiFlash。
TiFlash 以 Raft Learner 方式接入 Multi-Raft 组,使用异步方式传输数据,对 TiKV 产生非常小的负担。当数据同步到 TiFlash 时,会被从行格式拆解为列格式。
一般可以采用 binlog 方式,为了高效采用 raft 复制。
计算统一
构建好一个支持标准 SQL 的 TiDB-Server,将列存的信息暴露给 TiDB-Server,设计成一个新的统计信息规则——CBO cost 模型。让 TiDB-Server 的优化器可以通过新的 cost 模型来自由地选择数据寻址的方式,形成一个既包括了行式的存储和列式存储的统一的执行计划。
比如:
| |
该语句典型的有表关联、表过滤、聚合等操作。 TiDB-Server 会根据数据统计期评估,最终只需要计算 sales 表中的 price 列的平均值,没有必要读取其他列的信息,同时因为在 batch_id 列上有二级索引,最优的方式可能是通过二级索引进行数据过滤,过滤完的数据再去 TiSpark 进行寻址,然后并在列存里进行读取和聚合。这样既节省了 IO,又降低了网络传输的带宽。整个 SQL 一部分是通过了行存进行过滤,一部分通过列存进行了预聚合,通过优化器串联在一起。
MMP引擎
MMP引擎也就是并行计算。MMP架构是将任务并行地分散到多个服务器和节点上,在每个节点上,计算完成后,将各自部分的结果汇聚到一起得到结果。
如何实现 join 下推
如果要让一个 join 多个节点并行计算,需要将 join 的两个表的分片在节点的分布尽量一致,不一致需要通过网络将分片临时拷贝为一份临时数据进行 join 和计算,该过程就是 shuffle。MMP 计算模型本质上是通过网络与存储成本来置换计算资源。

如果启动 MMP 计算,首先在各个 TiFlash 节点将多表关联的结果进行数据分布一致,即上图中 TiFlash 上的红色箭头。接下来每个 TiFlash 节点上面的 MPP Worker 负责将表 Join 在多个节点的并行进行计算,最终将每个节点的临时结果返回到 TiDB-Server 进行再计算。
HTAP下一步探索
- 分布式数据库在大数据规模下提供 HTAP 的基础
- TiDB-Server 最大程度下推算法与 Hash Join 关键算子提供基础 AP 能力
- 借助生态,让 Spark 运行在 TiKV 之上
- 行列混合引擎,列式引擎提供实时写入能力
- 行列引擎采取 Raft-Base replication,解决数据同步效率
- TiDB-Server 统一技术服务
- MPP 解决技术节点的扩展性与并行计算 TiDB HTAP 的发展路径中既有产品内嵌功能,又有生态的数据连同,这是两套工程化的思路。
HTAP 会逐步转化为是数据服务统一的代名词:
- 产品内嵌功能的迭代,由一些具体产品完成 HTAP
- 整合多个技术栈与产品,并进行数据的连通,形成服务的 HTAP 过去一段时间,OLAP的场景基本基于数仓,流计算的发展将数仓分了几个阶段,最早的批处理,即 ETL 的离线数仓;批、流结合的 Lambda 架构;流计算为主的 Kappa 架构。在此基础上又可以和 OLTP 技术进行融合,如分区、列式存储、并行计算等。
流计算在复杂计算中的天然限制可以在分布式 HTAP 中得到解决。流计算的实时计算能力为不同的数据技术栈,以产品提供了丰富多样的数据连同能力。流计算加基于分布式关系数据的 HTAP 产品,将形成更有爆发力的 HTAP 的数据服务。
TiDB关键技术创新
分层的分布式架构
三个分布式系统:
- 分布式的 KV 存储系统
- 分布式的 SQL 计算系统
- 分布式的 HTAP 架构系统
自动分片与调度
自动分片技术时更细维度弹性的基础:
全局有序的 KV map
按照等长大小策略自动分片(96M)
每个分片是连续的 KV,通过 Start/End Key 来寻址
称分片为 Region,是复制、调度的最小单位 自动merge:
96 MB 自增分片
20 MB 合并分片

Multi-Raft 将复制组更离散
- Raft、Multi-raft
- leader、follower、learner
- 强主模式、读写在 leader 上
- 4.0 版本开启 follower read Raft 是一个强一致算法,保证了 RPO 为0,业务数据所能容忍的数据丢失量为零,在很多金融级的场景里至关重要。TiKV 设计中,把自动分片也就是 Region 机制与 Raft 进行了结合,形成了以分片为单位的复制组,即 Region base Multi-Raft。一套集群可以同时存在几十万个独立的复制组,这种设计大大提升了整个集群的整体可用性以及多点写入,同时大大优化了 RTO 灾难发生时恢复的时长。
基于 Multi-Raft 实现写入的线性扩展:新增一个物理节点时,意味着整个集群的写入容量会进行线性增长。
跨IDC单表多点写入
Region base Multi-Raft 机制,实现了一个表可以同时有多个写入点,TiKV 的调度机制,可以识别单个节点的物理信息,比如 IDC、REC(机柜)、Host(机架)、宿主机等,并进行约束与绑定。
去中心化的分布式事务
去中心化的两阶段提交,解决了事务能力的扩展性。
TiDB 5.0 以上版本,针对 OLTP 常见的高并发、小数据量的写入场景,TiDB 事务在第二阶段提交采取了异步处理的方式(Async commit),变相实现了 1PC 的效果,大大优化了分布式事务里 2PC 通用的延迟问题。
其中也有两个大的挑战:
如何确定所有 key 已被 prewrite
如何确定事务提交的时间戳 解决:
将所有事物的行的 key 与事物的 Primary key(状态位)进行索引的 mapping
通过统一 PD 来保障全局时间递增
Local Read and Geo-partition
Geo-partition 指多滴多活跨地域数据分布。
TiDB 5.0 中将中央的授时服务改为了分布式授时服务,能够提高场景的数据性能以及降低延迟。可以在多个 IDC 甚至跨洲际同时提供数据服务,也可以按照本地提供数据安全合规,不出境的方式来访问数据的场景。
总结:
- 多地部署支持,低延时访问
- 数据安全合规,符合数据不出境的场景
- 支持异步多活容灾
- 支持冷热数据分离
TP和AP融合
更大数据容量下的 TP 和 AP 融合:
- TiDB 引入了实时更新的列式引擎,既解决了资源隔离,又提升了 AP 效率
- 列存上引入 MPP 模型,实现 SQL join 的下推与并行处理
- 通过 Raft-base replication 实现时效性
- 融合大数据生态,比如 TiSpark
数据服务统一
TiDB 的 CBO 可以采集行列 Cost 模型进行配置,并同步收集不同引擎的统计信息,统一进行最佳执行路径的选择。
9.5 - SQL引擎
Introduction
SQL引擎
9.5.1 - 01.SQL解析器介绍
背景
传统的关系型数据库都支持 SQL 查询,另外在大数据领域,为了降低大数据的学习成本和难度方便用户,都开始支持 SQL 查询。SQL 查询让更多的用户可以方便快捷地查询数据,极大降低了学习门槛。
解析流程
通常解析器主要包括:
- 词法解析
- 语法解析
- 语义解析
词法解析
根据构词规则识别字符并切割成一个个的词条,如遇到空格进行分割,遇到分号时结束词法解析。
语法解析
语法分析的任务会在词法分析的结果上将词条序列组合成不同语法短句,组成的语法短句将与相应的语法规则进行适配,若适配成功则生成对应的抽象语法树,否则报会抛出语法错误异常。
语义解析
语义分析的任务是对语法解析得到的抽象语法树进行有效的校验,比如字段、字段类型、函数、表等进行检查。
常用SQL解析器
C/C++中,可以使用 LEX 和 YACC 来做词法分析和语法分析;Java中,可以使用 JavaCC 或 ANTLR 。
ANTLR
ANTLR是一款功能强大的语法分析器生成器,几乎支持所有主流变成语言的解析(antlr/grammars-v4)。可以用来读取、处理、执行和转换结构化文本或者二进制文件。在大数据的一些SQL框架里面有有广泛的应用,比如Hive的词法文件是ANTLR3写的,Presto词法文件也是ANTLR4实现的,SparkSQLambda词法文件也是用Presto的词法文件改写的,另外还有HBase的SQL工具Phoenix也是用ANTLR工具进行SQL解析的。
执行过程
- 实现词法文件(.g4)
- 生成词法分析器和语法分析器
- 生成抽象语法书(AST)
- 遍历AST,生成语义树
- 访问统计信息
- 生成逻辑执行计划
- 生成物理执行计划
Parser
Parser用来识别语言程序,包括两个部分:
词法分析器:关键字、标识符;
语法分析器:基于词法分析结果构造语法分析树。 转换过程:
字符流
Token流
(语法分析树)非叶子节点
(语法分析树)叶子结点
Grammar
ANTLR提供了很多常用语言的语法文件(antlr/grammars-v4)。
使用语法注意事项:
- 语法名称和文件名要一致
- 语法分析器规则以小写字母开始
- 词法分析器规则以大写字母开始
- 用'string'单引号引出字符串
- 不需要指定开始符号
- 规则以分号结束
实现四则运算
实现的基本流程:
- 按照ANTLR4的规则编写自定义语法的语义规则, 保存成以g4为后缀的文件;
- 使用ANTLR4工具处理g4文件,生成词法分析器、句法分析器代码、词典文件;
- 编写代码继承Visitor类或实现Listener接口,开发自己的业务逻辑代码。 一般有两种模式:
Visitor模式
1antlr4 -package com.chnherb.sql -no-listener -visitor .\xxx.g4Listener模式
1antlr4 -package com.chnherb.sql -listener .\xxx.g4
定义词法规则文件
CommonLexerRules.g4
Expand/Collapse Code Block
| |
定义语法规则文件
LibExpr.g4
Expand/Collapse Code Block
| |
编译生成文件
| |
执行命令:
| |
编写示例代码
示例文本:
| |
逻辑代码:Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package com.chnherb.sql;
import java.util.HashMap;
import java.util.Map;
/**
* 重写访问器规则,实现数据计算功能
* 目标:
* 1+2 => 1+2=3
* 1+2*4 => 1+2*4=9
* 1+2*4-5 => 1+2*4-5=4
* 1+2*4-5+20/5 => 1+2*4-5+20/5=8
* (1+2)*4 => (1+2)*4=12
*/
public class LibExprVisitorImpl extends LibExprBaseVisitor<Integer> {
// 定义数据
Map<String,Integer> data = new HashMap<String,Integer>();
// expr (NEWLINE)? # printExpr
@Override
public Integer visitPrintExpr(LibExprParser.PrintExprContext ctx) {
System.out.println(ctx.expr().getText()+"="+visit(ctx.expr()));
return visit(ctx.expr());
}
// ID '=' expr (NEWLINE)? # assign
@Override
public Integer visitAssign(LibExprParser.AssignContext ctx) {
// 获取id
String id = ctx.ID().getText();
// // 获取value
int value = Integer.valueOf(visit(ctx.expr()));
// 缓存ID数据
data.put(id,value);
// 打印日志
System.out.println(id+"="+value);
return value;
}
// NEWLINE # blank
@Override
public Integer visitBlank(LibExprParser.BlankContext ctx) {
return 0;
}
// expr op=('*'|'/') expr # MulDiv
@Override
public Integer visitMulDiv(LibExprParser.MulDivContext ctx) {
// 左侧数字
int left = Integer.valueOf(visit(ctx.expr(0)));
// 右侧数字
int right = Integer.valueOf(visit(ctx.expr(1)));
// 操作符号
int opType = ctx.op.getType();
// 调试
// System.out.println("visitMulDiv>>>>> left:"+left+",opType:"+opType+",right:"+right);
// 判断是否为乘法
if(LibExprParser.MUL==opType){
return left*right;
}
// 判断是否为除法
return left/right;
}
// expr op=('+'|'-') expr # AddSub
@Override
public Integer visitAddSub(LibExprParser.AddSubContext ctx) {
// 获取值和符号
// 左侧数字
int left = Integer.valueOf(visit(ctx.expr(0)));
// 右侧数字
int right = Integer.valueOf(visit(ctx.expr(1)));
// 操作符号
int opType = ctx.op.getType();
// 调试
// System.out.println("visitAddSub>>>>> left:"+left+",opType:"+opType+",right:"+right);
// 判断是否为加法
if(LibExprParser.ADD==opType){
return left+right;
}
// 判断是否为减法
return left-right;
}
// '(' expr ')' # Parens
@Override
public Integer visitParens(LibExprParser.ParensContext ctx) {
// 递归下调
return visit(ctx.expr());
}
// ID # Id
@Override
public Integer visitId(LibExprParser.IdContext ctx) {
// 获取id
String id = ctx.ID().getText();
// 判断ID是否被定义
if(data.containsKey(id)){
// System.out.println("visitId>>>>> id:"+id+",value:"+data.get(id));
return data.get(id);
}
return 0;
}
// INT # Int
@Override
public Integer visitInt(LibExprParser.IntContext ctx) {
// System.out.println("visitInt>>>>> int:"+ctx.INT().getText());
return Integer.valueOf(ctx.INT().getText());
}
}
Main函数:
Expand/Collapse Code Block
| |
解析CSV文件
裁剪g4文件
定义SelectBase.g4文件。直接参考Presto源码,g4文件并不需要从零开发,只需要基于Presto的g4文件裁剪即可。
核心规则为: SELECT selectItem (',' selectItem)* (FROM relation (',' relation)*)?
查询数据表被抽象成了relation。
裁剪后的内容如下:
Expand/Collapse Code Block
| |
生成代码
| |
语法树节点

解析类
基于visitor模式实现解析类AstBuilder,以visitQuerySpecification为例:
Expand/Collapse Code Block
| |
解析出查询的数据源和具体字段,封装到QuerySpecification对象中。
Statement查询数据
将用户输入的语句解析成ParseTree,对其遍历生成Statement对象。核心代码如下:
| |
Statement对象使用:
- Query类型的Statement有QueryBody属性。
- QuerySpecification类型的QueryBody有select属性和from属性。
- 从from属性中获取待查询的目标表Table。这里约定表名和csv文件名一致。
- 从select属性中获取待查询的目标字段SelectItem。这里约定csv首行为title行。 流程如下:
- 获取查询的数据表以及字段。
- 通过数据表名称定为到数据文件,并读取数据文件数据。
- 格式化输出字段名称到命令行。
- 格式化输出字段内容到命令行。
编写代码
Expand/Collapse Code Block
| |
Calcite
简介
与ANTLR不同,Apache Calcite大大简化了SQL的解析流程,不需要定义接口、生成代码。
数据库包含的常用功能:
- query language
- query optimization
- query execution
- data management
- data storage Calcite 设计之初主要关注前三者,将后面两个数据管理和数据存储交给计算/存储引擎。专注于上层通用的模块,控制系统的复杂性。
同时,Calcite也复用了一些组件,如使用 JavaCC 来将SQL语句转为Java代码,进而转化成AST。另外为了支持灵活的元数据功能,Calcite需要支持运行时编译Java代码,但默认的JavaC太重,使用了轻量开源的 Janino。
常用的大数据组件都有集成 Calcite,Hive就是自己做了SQL解析,只使用了Calcite的查询优化功能。而像Flink从解析到优化都直接使用了Calcite。
主要模块
- JDBC Client:支持 JDBC Client 的应用
- SQL Parser and Validator:用来SQL解析和校验
- Expressions Builder:支持SQL解析和校验的框架对接
- Operator Expressions:处理关系表达式
- Metadata Provider:支持外部自定义元数据
- Pluggable Rules:定义优化规则
- Query Optimizer:(核心模块)专注于查询优化
解析SQL
pom依赖
| |
实现代码
Expand/Collapse Code Block
| |
Reference
https://github.com/antlr/antlr4
https://github.com/antlr/grammars-v4
https://github.com/apache/calcite
https://github.com/smartloli/EFAK
https://prestodb.io/docs/current/
9.5.2 - 02.Join操作
背景
业务开发使用数据库时,通常规定不允许使用过多的表Join,如阿里巴巴开发手册中:
【强制】超过三个表禁止Join。需要Join的字段,数据类型必须绝对一致;多表关联查询时,保证被关联的字段需要有索引。说明:即使双表Join也要注意表索引、SQL性能。
大数据数仓中,既有星型结构和雪花结构,但最终交付业务使用的大多是宽表。
Join基本原理
Join分类
Join分为如下几类:
- Cross Join
- Inner Join
- Outer Join
- full join
- left join
- right join
Cross Join
交叉连接,返回连接的两个表所有数据行的笛卡尔积,一般不加条件限制。使用:
| |
Inner Join
Inner join 获取两张表的交集,是内联查询,不是产生笛卡尔集,结合ON子句使用,直接基于join condition做连接,生成的join集合就是最终的输出结果,产生的中间数据更小。
Outer Join
Outer join 获取两张表的并集,是内联查询。
- full join:产生A和B的并集
- left join:产生表A的完全集,而B表中匹配的则有值,没有匹配的则以NULL值取代。
- right join:产生表B的完全集,而A表中匹配的则有值,没有匹配的则以NULL值取代。
关联算法
常见的关联算法有三大类,分别是
- 嵌套循环(Nested Loop Join)
- 排序归并(Sort-Merge Join)
- 哈希(Hash Join)
Join工程化理论
火山模型
SQL语法支持的操作类型非常丰富:查询表(TableScan)、过滤数据(Filter)、排序(Order)、限制(Limit)、字段进行运算(Project)、 聚合(Group)、关联(Join)等。为了实现上述的能力,需要一个具备并行化能力且可扩展的架构。
1994年Goetz Graefe在论文《Volcano-An Extensible and Parallel Query Evaluation System》提出了一个架构设计思想,这就是大名鼎鼎的火山模型,也称为迭代模型。火山模型包含了文件系统和查询处理两个部分。

来源于《Balancing vectorized execution with bandwidth-optimized storage》
职责分离
将不同操作独立成一个的Operator,Operator采用open-next-close的迭代器模式。如一般的SQL语句对应到Scan、Select、Project三个Operator,数据交互通过next()函数实现。
Presto中的Operator:
- SourceOperator
- TableScanSourceOperator
- OrderOperator
- LimitOperator
- TaskOutputOperator
动态组装
Operator基于SQL语句的解析实现动态组装,多个Operator形成一个管道(pipeline)。
Presto在火山模型的基础上,吸收了其它思想做了如下优化:
- Operator数据处理优化成一次一个Page,而不是一次行(也称为tuple)。
- Page的存储采用列式结构。即相同的列封装到一个Block中。 批量处理结合列式存储奠定了向量化计算的基础**,也是数据库领域的优化方向**。
批量处理和列式存储
Presto源码中,随处可见 Page 和 Block。
通常 OLAP 场景不需要读取所有字段,于是衍生了列式存储,如下结构:

将数据封装成Page在各个Operator中流转,一方面避免了对象的序列化和反序列化成本,另一方面相比行(tuple)的方式降低了函数调用的开销。类似集装箱运货降低运输成本的思想。
示例代码:
Expand/Collapse Code Block
| |
Join工程化实践
基本流程
- Parser: 借助ANTLR的能力即可实现SQL语法的检测。
- Binding: 基于SQL语句生成AST,利用元数据检测字段和表的映射关系以及Join条件的字段类型。
- Planner: 基于AST生成查询计划。
- Executor: 基于查询计划生成对应的Operator并执行。
落地事项
- 支持所有的Join语义
- 分布式能力
- 性能优化
- 多表Join的顺序选择
- 大表与小表Join
- Semi Join优化
- Join算法倾斜
示例:Nested Loop Join
以 Nested Loop Join 算法为例,Presto是拆解为两个阶段:
- 组合阶段
- 过滤阶段 实现JoinOperator时,只需负责两个表数据的笛卡尔积组合即可。核心代码如下:
Expand/Collapse Code Block
| |
9.5.3 - 03.统计计数
背景
统计计数是一个非常常见场景。除了通常的计数,统计不重复元素个数的需求也非常常见,这种统计称为基数统计。分布式SQL引擎,计数的实现原理值得深入研究,特别是基数统计。关于普通计数和基数计数,最典型的例子莫过于PV/UV。
主要方法
在SQL语法里面,基数统计对应到count(distinct field)或者aprox_distinct()。通常做精确计数统计需要用到Set这种数据结构。Set不仅可以获得数量信息,还能不重不漏地获取每一个元素。
Set内部有两种实现实现原理:Hash和Tree。
在海量数据的前提下,Hash和Tree有一个致命的问题:内存消耗,而且随着数据量级的增长,内存消耗也是线性增长。
面对Set内存消耗的问题,通常有两种思路:
- 选取其他内存占用更小的数据结构,例如bitmap;
- 放弃精确,从数学上寻求近似解,典型的算法有Linear Count和HyperLogLog。
Bitmap
在数据库领域Bitmap并不是新事物,一般用作索引,称为位图索引。所谓位图索引,就是用一个bit位向量来记录某个字段值是否存在于对应的记录。它有一个前置条件:记录要有永久的编号,类似于从1开始的自增主键。
位图向量的构建
对于数据表的一个字段,如果记录数为n且字段的取值基数为m,那么会得到一个m*n的位图。
多个字段即得到多个这样的位图。
位图向量的应用
单个条件:
直接取该字段m*n的位图,统计其中 1 的个数。
多个条件:
取对应多个字段的位图,多个向量进行交集运算,然后统计 1 的个数。
优缺点
优点:
节省内存(数据分布密集压缩空间更大)
二进制位操作,执行效率高 缺点:
非数值型字段,需要额外转换处理
Linear Count
Linear Count简称LC算法(利用数学的概率和统计学知识)。
算法描述如下:
- 初始化:给定m个房间,房间存储数字,初始化为0。
- 迭代执行:对于要进行基数统计的集合,用一个哈希函数处理集合中的每一个元素。通过哈希函数处理后,元素就可以放置到一个房间中。
- 收尾:统计m个房间中空房间的数量U。
- 结论:集合中不重复元素的个数估计值可以通过如下公式计算:n=-m*log(U/m)。 随着m和n的增大,m大约为n的十分之一。
HyperLogLog
HyperLogLog简称HLL算法,它有如下的特点:
- 可以实现由极小的内存开销统计出巨量的数据。在 Redis中实现的HyperLogLog,只需要12K内存就能统计2^64个数据。
- 可以方便实现分布式扩展。(对算法在业务系统中落地非常关键)
基础理论
伯努利实验
如抛硬币,随着次数地增多,对应的值也就越趋近一个值。这样将统计问题转换成概率论中参数估计的问题。
调和平均数
概率中的极值问题导致最后得到的值不稳定。对于极值的处理策略是多实验几轮,通过平均值来消除极值的影响。
数学上其实有许多的平均数计算方式:算术平均数、几何平均数、平方平均数。选用调和平均数主要是消除极值的影响。
核心流程
MapReduce的核心流程:
- Input
- Splitting
- Mapping
- Shuffling
- Reducing
- Final result Presto核心流程也是类似的,先分组聚合,然后汇总聚合。
方案实现
count distinct
以id为主key, 对数据进行hash分发,进行部分聚合,最终整体聚合。依然是map-reduce的思路,只不过数据按id进行了分发。
aprox_distinct
免去了基于id的hash分发策略,减少了一个stage。以Presto为例,基础框架airlift中封装了HyperLogLog算法的实现,采用的函数是MurMurHash3算法,生成64位散列值。前6位用于计算当前散列值所在分组m。实现过程中还有一个很有意思的细节:基于待统计的数据量,实现中同时采用了Linear Count算法和HyperLogLog算法。
小结
基数统计是一个非常消耗内存的操作,特别是在分布式系统背景下,不仅消耗内存,而且涉及大量网络数据传输。
分析对应的业务场景,如果可以提供近似值而非精确值,能大幅度降低系统消耗和响应时间,提升用户体验。或者在设计产品时,对于一些场景的计数,可以优先提供近似估计,如果确实需要精确计数,在管理好响应时间预期下,再提供查询精确值的接口。
9.5.4 - Presto简介
背景
Facebook的数据仓库存储在少量大型Hadoop/HDFS集群,之前Facebook的科学家和分析师一直依靠Hive来做数据分析。但Hive使用MapReduce作为底层计算框架,是专为批处理设计的。随着数据越来越多,使用Hive进行一个简单的数据查询可能要花费几分到几小时,显然不能满足交互式查询的需求。Facebook也调研了其他比Hive更快的工具,但它们要么在功能有所限制要么就太简单,以至于无法操作Facebook庞大的数据仓库。
2012年开始试用的一些外部项目都不合适,Facebook决定自己开发,即Presto。目前该项目已经在超过1000名Facebook雇员中使用,运行超过30000个查询,每日数据在1PB级别。Facebook称Presto的性能比Hive要好10倍以上。2013年Facebook正式宣布开源Presto。
简介
Presto是由facebook 开源的分布式的MPP(Massive Parallel Processing)架构的SQL查询引擎。基于全内存计算(部分算子数据也可通过session 配置spill到本地磁盘),并且采用流式pipeline的方式处理数据使其能够节省内存的同时,更快的响应查询。
特点
多数据源 Presto可以支持MySQL、PostgreSQL、cassandra、Hive、Kafka等多种数据源查询。也可以帮助从其驻留位置查询数据,例如Hive,Cassandra,专有数据存储或关系数据库。
支持SQL Presto支持部分标准SQL对数据进行查询,并提供SQL shell进行SQL查询。但不支持存储过程,不适合大表Join操作,因为Presto是基于内存的,多张大表关联可能给内存带来压力。
扩展性 Presto有很好的扩展向,可以自定义开发特定数据源的Connector,使用SQL分析指定Connector中的数据。
混合计算 在Presto中可以根据业务需要使用特定类型的Connector来读取不同数据源的数据,进行join关联计算、合并查询。
基于内存计算,高性能 基于内存计算的,减少磁盘IO,计算更快。性能是Hive的10倍以上,能够处理PB级别的数据,但并不是把PB级别的数据一次性加载到内存中计算,而是根据处理方式,例如聚合场景,边读取数据、聚合、再清空内存,循环往复。如果使用Join查询,那么就会产生大量的中间数据,速度会变慢。
流水线 由于Presto是基于PipeLine进行设计的,因此在进行海量数据处理过程中,终端用户不用等到所有的数据都处理完成才能看到结果,而是可以向自来水管一样,一旦计算开始,就可以产生一部分结果数据,并且结果数据会一部分接一部分的返回到客户端。
架构简单 包括一个coordinator和多个worker。
Presto与Hive比较
| Presto | Hive | |
|---|---|---|
| 查询语言 | ANSI SQL | HiveQL |
| 速度 | 比Hive的速度快5-10倍 | 比Presto慢 |
| 自定义代码插件 | 没有 | 允许用户插入自定义代码 |
| 语法差异 | 有 | 有 |
| 数据限制 | 可以处理有限的数据量 | 可以处理大批量数据 |
| 长时间运行的查询 | 超过48小时就会终止 | 可以查询长期运行的查询 |
Presto与Spark SQL比较
| Presto | HiveSpark SQL | |
|---|---|---|
| 重点 | 强调查询,支持BI报表 | 强调计算,数据的ETL加工 |
| 架构 | MMP主从架构,简单,一个协调器多个Worker | MMP主从架构,复杂,很多层,RDD的弹性构建,为作业进行资源管理和协商等 |
| 内存存储 | 基于内存,不够就OOM | 内存+落磁盘 |
| 资源申请 | 预先申请CPU/内存,coordinator和worker一直运行 | 实时申请资源,需要多少资源申请多少 |
| 数据处理 | 批处理管道处理模式,完成就可以将其发送到下一个任务,大大减少各种查询的端到端响应时间 | 数据需要在进入下一阶段之前完全处理 |
| 优化措施 | 基于成本的优化器(CBO),速度更快 | 基于规则的优化(RBO),复杂查询上执行更好的操作,速度更慢 |
| 运行时间 | 作为服务一直运行,更容易利用缓存 | 一个任务,作业分发和启动都需要时间 |
架构

Presto查询引擎是一个Master-Slave的架构,组成部分:
- 一个Coordinator节点
- 一个Discovery Server节点(通常内嵌于Coordinator节点中)
- 多个Worker节点组成 Coordinator负责解析SQL语句、生成执行计划、分发执行任务给Worker节点执行。
Worker节点负责实际执行查询任务。Worker节点启动后向Discovery Server服务注册,Coordinator从Discovery Server获得可以正常工作的Worker节点。如果配置了Hive Connector,需要配置一个Hive MetaStore服务为Presto提供Hive元信息,Worker节点与HDFS交互读取数据。
Coordinator
协调器(Coordinator)是整个系统的中心节点,负责接收客户端请求、解析查询语句、编译执行计划、并发控制、任务调度、结果合并等。具体来说,协调器的职责包括以下几个方面:
- 接收并解析查询请求:协调器负责接收客户端发送的SQL查询请求,并对请求进行解析,获取查询的元数据、查询语句等信息。
- 编译执行计划:协调器根据查询语句、元数据等信息生成查询的执行计划,包括任务划分、任务依赖关系等。
- 并发控制和任务调度:协调器负责控制查询的并发度和任务调度,根据查询的执行计划将任务分配给不同的工作节点执行。在任务分配时,协调器会考虑数据本地性、节点负载均衡等因素,尽可能地提高查询效率。
- 监控和管理任务执行:协调器负责监控任务的执行情况,并根据任务执行情况进行调度和管理。如果某个任务失败或超时,协调器会重新分配任务或者取消查询。
- 结果合并和返回:协调器负责将各个任务的执行结果进行合并,并将结果返回给客户端。
Worker
Worker是Presto集群中的工作节点,它们负责执行协调器分配的任务,处理查询请求并生成结果。Worker的主要职责包括以下几个方面:
- 接收并处理任务:Worker从协调器接收任务,根据任务的要求从存储系统中获取数据并执行计算操作。
- 执行计算任务:Worker将接收到的任务分解为小任务,并利用多个CPU核心和内存执行计算任务。在执行任务时,Worker会将数据读取到内存中,并使用Presto定义的内存数据结构进行计算。
- 返回结果:Worker完成任务后,将结果返回给协调器,协调器会对结果进行汇总,最终生成查询结果并返回给客户端。
- 资源管理:Worker需要负责管理自己的资源,包括CPU、内存和磁盘等。Worker需要监控自己的资源使用情况,以确保任务能够正确执行并不会消耗过多的资源。
- 处理异常:如果任务执行过程中出现异常,Worker需要能够及时捕获并上报给协调器,同时清理异常的状态。 Worker是Presto集群中的核心节点,它们负责处理查询请求、执行计算任务并返回结果。Worker需要高效地利用资源并保证任务执行的正确性,从而提高Presto集群的性能和可靠性。
模型
Connector
Connector是一种抽象的数据源接口,用于连接不同的数据源,例如Hive、MySQL、PostgreSQL、Cassandra等。每个Connector实现了一组标准的接口,包括Metadata、Split、PageSource和RecordSet等,以便与Presto核心系统进行交互和协作。
每种Connector都实现了Presto中标准的SPI接口,只要实现了Presto中标准的SPI接口,就可以轻易实现适合自己需求的Connector。
- Metadata接口:用于提供数据源的元数据信息,例如表和列的结构、数据类型、分区信息等。Metadata接口还提供了一些用于查询和筛选数据的方法,例如getTables、getColumns、getPartitionKeys、getPartitions等。
- Split接口:用于将数据源划分成多个数据块(Split),每个Split包含一部分数据和相关的元数据信息。Split接口提供了一些用于生成和管理Split的方法,例如getSplits、getSplit、isRemotelyAccessible等。
- PageSource接口:用于读取Split中的数据,并返回一个或多个数据页(Page)。PageSource接口提供了一些用于读取数据和处理异常的方法,例如getReader、getPosition、isFinished等。
- RecordSet接口:用于将数据页(Page)转换为行记录(Record),并提供一些用于访问和处理记录的方法,例如getColumnTypes、nextBatch、getRowCount等。 通过Connector接口,Presto可以访问和处理不同的数据源,支持跨数据源的查询和联合查询。Connector还提供了一些可插拔的机制,例如分区发现、列注释、列别名等,以提高查询性能和效率。
Catalog
Catalog是一个抽象的概念,表示数据源的集合,可以是一个数据库、一个Hive实例、一个S3存储桶等等。Catalog包含了一组Schema(模式),每个Schema表示一个数据库或者命名空间,包含了一组Table(表),每个Table表示一个数据表。Catalog不存储数据,它仅仅是数据源的描述信息,例如表的结构、分区信息等。Presto的Catalog提供了一些用于管理和查询数据源的方法,例如创建和删除Catalog、列出Catalog、切换Catalog、列出Schema、切换Schema、列出Table、描述Table等。
Presto中的Catalog,相当于数据库的一个实例。在Presto的配置文件中,以.properties结尾,每个properties就对应了Presto中的一个Catalog。
Schema
Schema是一个抽象的概念,表示数据库或者命名空间,用于组织和管理表。每个Schema包含了一组Table(表),每个Table表示一个数据表。Schema的作用类似于关系型数据库中的数据库,可以帮助用户组织和管理表,并提供更好的隔离和权限控制。一个Catalog中可以有多个Schema。
Table
Table表示一个数据表,包含了表的结构和数据。每个Table属于一个特定的Schema(数据库或命名空间),可以由Catalog(数据源)中的Schema映射而来,也可以由用户在Presto中创建。
Presto支持的Table类型包括:
- Native Table:在Presto中创建的Table,通常是一些基于文件、Hive、关系型数据库等数据源的虚拟表。
- External Table:在Presto中定义的对外部数据源的引用,例如S3存储桶、HDFS文件系统等。
- System Table:Presto内置的一些元数据表,用于获取关于集群、任务、查询等运行时信息。 Presto的Table可以使用SQL语句进行查询、过滤、聚合等操作,支持标准的SQL查询语法,例如SELECT、FROM、WHERE、GROUP BY、HAVING、JOIN等关键字。Presto还支持复杂的查询操作,例如嵌套查询、子查询、多级连接、窗口函数等。
Presto的Table可以使用各种文件格式存储数据,包括文本文件(CSV、JSON、XML等)、列式存储(Parquet、ORC等)、序列化存储(Avro、Thrift等)等。Presto还支持对Table进行分区和桶(bucket)操作,以便于提高查询性能和优化数据访问。
执行过程
主要概念
Page
Presto中最小数据处理单元是一个Page对象,Page通常是列存储格式的,包含若干行数据。通过Page将数据从一个节点传递到另一个节点。
Page对象的数据结构如下图所示。一个Page对象包含多个Block对象,每个Block对象是一个字节数组,存储一个字段的若干行。多个Block横切的一行是真实的一行数据。一个Page最大1MB,最多16*1024行数据。

Stage
Stage是Presto查询计划中的一个执行阶段,可以看作是一个有向无环图(DAG)中的一个节点。每个Stage都是由一个或多个Task组成,每个Task处理一个或多个Split。Stage的主要职责是将查询计划划分为多个阶段并协调Task之间的数据传输和计算。
Task
Task是Stage的子节点,是Presto中的计算任务单元。每个Task都处理一个或多个Split,它们可以在不同的Worker上运行,以利用并行计算来加速查询。Task通过Page进行数据交换,将输入数据处理为输出数据,并将输出传递给下一个Task或Stage。
RemoteTask
表示在分布式计算中的一个计算任务。与Task的功能类似,但它在集群中的另一个节点上执行计算。Presto通过协调器将数据分发到所有的节点,然后在每个节点上运行RemoteTask,最后将计算结果聚合到协调器上。
Split
Split是数据的最小单元,表示数据在存储系统中的一个分片。Presto将查询分解成多个Split,并将它们分配给不同的Task来处理。Split可以是文件、表、分区或其他数据集合。
SqlStageExecution
表示查询执行的阶段的对象,它负责将查询拆分为多个Task,并跟踪这些Task的状态和结果。当SqlStageExecution对象创建时,它会通过ConnectorManager获取查询数据源的元数据信息,然后将查询分解为多个Stage。
Operator
每个Operator表示查询计算的一个操作,它执行特定的计算逻辑。在一个Task中,可能包含多个Operator,这些Operator可以共享输入数据,从而减少数据传输和复制的开销。当一个Operator完成计算时,它将结果写入输出Page。
查询计划
查询计划是指将查询语句转换为一系列查询操作和数据流的执行计划。查询计划由Presto的优化器生成,根据查询的语义和查询数据的结构选择最优的执行计划。
Presto查询计划的主要组成部分如下:
- Source:Source表示查询数据的来源,可以是表、视图、函数等。在查询计划中,每个Source都对应一个SourceNode。
- Filter:Filter表示查询条件,它通常用于过滤出符合查询条件的数据。在查询计划中,Filter通常表示为一个FilterNode。
- Project:Project表示查询的投影操作,它用于选择查询中需要的列。在查询计划中,Project通常表示为一个ProjectNode。
- Join:Join表示查询中的连接操作,它将两个或多个数据源合并在一起。在查询计划中,Join通常表示为一个JoinNode。
- Group By:Group By表示查询中的聚合操作,它将数据按照指定的列进行分组,并计算每个组的聚合值。在查询计划中,Group By通常表示为一个AggregationNode。
- Order By:OrderBy表示查询中的排序操作,它将查询结果按照指定的列进行排序。在查询计划中,OrderBy通常表示为一个SortNode。 在Presto中,查询计划是一个有向无环图(DAG),每个节点表示一个查询操作,每个边表示数据流的传递。在执行查询时,Presto将查询计划转换为一组Task,每个Task对应查询计划中的一个节点。Task可以在多个节点之间并行执行,以利用分布式系统中的多核和多节点资源,提高查询性能。
执行流程
- 解析和编译查询语句:Presto接收到一个查询请求后,首先会对查询语句进行语法分析和语义分析,并生成执行计划。
- 执行查询计划:Presto的执行计划是一个有向无环图(DAG),其中包含了各个任务(Task)和它们之间的依赖关系。Presto会根据执行计划中的任务划分为不同的阶段(Stage),并按照阶段依赖关系的拓扑顺序执行任务。
- 并行执行任务:Presto支持多节点、多线程的并行执行任务。在每个节点上,Presto会启动一个或多个Executor线程,每个Executor线程负责执行一个或多个任务。Presto还支持数据本地化(Data Locality)优化,尽可能地将任务分配给与数据在同一节点或同一机架的Executor线程。
- 合并结果:当所有任务执行完毕后,Presto会将各个任务的结果进行合并,并返回给客户端。在合并结果时,Presto会进行优化,例如对于聚合查询(例如SUM、AVG、MAX等),Presto会在每个节点上进行局部聚合(Partial Aggregation),并在最后一个任务上进行全局聚合(Final Aggregation)。
- 清理资源:Presto在执行查询后会释放占用的资源,包括内存、CPU、网络等。Presto还支持查询取消(Cancellation)和查询超时(Timeout)机制,以保证查询不会长时间占用集群资源。 在Presto中,数据是以Page的形式在节点之间传输。Page是Presto中的数据块,它是由一系列的行(Row)组成的,行中的每个字段都被序列化为一个字节数组。在任务执行过程中,Presto会将Page从一个节点传输到另一个节点,以便在不同节点之间共享数据。因为Page是Presto中最基本的数据单位,所以Presto的很多内部实现都是以Page为基础的。
Presto内存并行计算的过程可以概括为将查询分解为多个Stage,每个Stage包含多个Task,每个Task包含一个或多个Operator。Task会被分配给多个Worker并行执行,Worker之间通过网络传输数据和交换结果,最终返回给协调器的结果会被整合成最终的查询结果。在计算过程中,Presto使用内存中的Page传递数据,这种分布式计算模型可以有效提高计算性能和并发能力。
优化器
优化策略
优化器主要负责将查询计划转换为一组高效的执行计划,以便在分布式系统中高效地执行查询。Presto优化器的主要优化策略如下:
- 查询重写:Presto优化器会对查询进行重写,以利用索引、分区和其他优化手段来加速查询。重写后的查询可以更好地利用存储系统和网络带宽资源,从而提高查询性能。
- 消除冗余计算:Presto会分析查询计划,消除冗余计算,避免在计算过程中重复处理相同的数据。这样可以减少计算量和数据传输量,提高查询效率。
- 选择合适的Join算法:Presto优化器会根据查询计划中Join的大小、数据分布和数据类型等因素,选择合适的Join算法,包括Broadcast Join、Hash Join和Sort Merge Join等。
- 分区裁剪:对于分区表,Presto优化器会分析查询条件,并根据条件过滤掉不需要的分区,避免在查询过程中扫描所有分区,从而提高查询性能。
- 并行查询:Presto优化器会将查询计划分解成多个Stage和Task,以利用分布式系统中的多核和多节点资源,并行执行查询,提高查询效率。
- 动态编译:Presto优化器支持动态编译,即将查询计划转换为可执行的机器代码,并将其缓存起来,以便下次查询时可以直接使用,避免重复编译和优化,从而提高查询性能。 通过这些优化策略的应用,Presto可以实现高效、可扩展的分布式查询处理,适用于大规模数据集的查询分析场景。
主要优化点
Presto作为一个交互式的查询引擎,如何实现低延时查询,一些传统的SQL优化原理,主要是下面几个方面:
分布式查询:Presto使用分布式架构进行查询,可以将查询任务分配到集群中的多个节点上并行执行,从而提高查询的并发性和速度。
内存计算:Presto使用内存计算来处理数据,可以避免I/O瓶颈,提高计算速度。
压缩和编码:Presto使用高效的压缩和编码算法,可以减少数据传输的开销,提高查询的速度。
灵活的优化器:Presto具有灵活的查询优化器,可以自动优化查询计划以最大化查询性能。
支持多种数据源:Presto可以查询多种数据源,包括Hadoop、Cassandra、MySQL等,可以无缝访问不同数据源的数据,提高查询效率。 具体有以下几个点:
完全基于内存的并行计算
流水线执行
本地化计算
动态编译执行计划
优化使用内存和数据结构
近似查询
GC控制
流水线
节点内部流水线计算
Presto的查询处理流程是通过一系列的节点内部流水线计算来实现的,以下是Presto节点内部流水线计算的基本过程:
- 分割数据:Presto通过切分查询任务,将数据分割成多个小块,每个小块包含了一个或多个数据分片。这些小块可以在不同的节点上并行处理。
- 执行扫描:每个节点都会读取一些数据分片,并执行扫描操作,例如过滤、排序和聚合等。这些操作通常使用流水线计算模型,每个操作的结果会被传递到下一个操作中进行处理。
- 执行计算:扫描操作的结果会被传递到计算操作中,例如计算表达式、聚合函数和连接操作等。这些操作也通常使用流水线计算模型,每个操作的结果会被传递到下一个操作中进行处理。
- 执行输出:最后,节点会将计算操作的结果输出到客户端或下一个节点中进行处理。输出操作通常包括将数据序列化、压缩和发送等。 Presto节点内部流水线计算是高效的,利用了现代计算机硬件的并行性和内存计算能力,可以有效地处理大规模数据集,提高查询性能。
节点间流水线计算
Presto是一个分布式查询引擎,可以将查询任务分配到集群中的多个节点上并行执行,节点之间的流水线计算是Presto实现分布式查询的核心。
Presto的节点之间流水线计算的基本过程如下:
- 数据分片:查询任务的数据会被分割成多个小块,并分配给集群中的不同节点。每个节点会处理自己负责的数据分片,然后将结果传递给下一个节点。
- 执行扫描:每个节点会读取自己负责的数据分片,并执行扫描操作,例如过滤、排序和聚合等。这些操作通常使用流水线计算模型,每个操作的结果会被传递到下一个节点中进行处理。
- 数据重分片:为了确保节点之间的负载均衡,Presto会对数据进行重新分片,以便将数据均匀地分配给集群中的不同节点。
- 执行计算:重分片后,每个节点会执行计算操作,例如计算表达式、聚合函数和连接操作等。这些操作也通常使用流水线计算模型,每个操作的结果会被传递到下一个节点中进行处理。
- 执行输出:最后,节点会将计算操作的结果输出到客户端或下一个节点中进行处理。输出操作通常包括将数据序列化、压缩和发送等。 Presto的节点之间流水线计算是分布式的,需要高效地处理节点之间的数据传输和通信,以实现高性能的查询。Presto使用了一系列高效的网络传输协议和数据格式,以最小化数据传输的开销,并通过智能的节点调度算法动态调整节点的负载,以确保查询的性能和可靠性。
本地化计算
Presto的本地化计算指的是在一个节点上执行查询任务,而不需要将数据发送到其他节点进行处理。这种本地化计算可以有效地减少数据传输和通信的开销,提高查询的性能和效率。
Presto的本地化计算主要有以下两种方式:
- 全部本地化:当查询的数据全部位于一个节点上时,Presto会将查询任务分配到该节点进行处理,这种方式可以最大程度地减少数据传输和通信的开销,提高查询性能。
- 部分本地化:当查询的数据分布在多个节点上时,Presto会将查询任务分成多个子任务,其中一部分子任务会分配到本地节点进行处理,另一部分子任务会发送到其他节点进行处理。这种方式可以在尽可能减少数据传输和通信开销的同时,充分利用集群中所有节点的计算资源,提高查询效率。 Presto的本地化计算需要具备一定的硬件和软件支持,例如高速网络和内存计算等。此外,Presto还提供了一系列高级优化功能,例如动态过滤、预读取和预聚合等,可以进一步提高本地化计算的性能和效率。
Presto在选择Source任务计算节点时,对于每一个Split,会按照以下策略选择一些minCandidates:
优先选择与Split同一个Host的Worker节点
如果节点不够优先选择与Split同一个Rack的Worker节点
如果节点还不够随机选择其他Rack的节点 对于所有Candidate节点,选择assignedSplits最少的节点。除此之外,会按照以下策略优先选择选择节点:
负载均衡:调度器会根据每个节点的负载情况来选择计算节点。负载越低的节点,越有可能被选为计算节点。
资源利用率:调度器会计算每个节点的资源利用率,包括CPU、内存和磁盘等资源的使用情况。资源利用率越高的节点,越不可能被选为计算节点。
数据位置:如果Split的数据位于某个节点上,那么该节点很可能会被选为计算节点,以避免不必要的数据传输。
处理时间:调度器会根据Split的大小和节点的处理能力,计算每个节点处理该Split所需的时间。处理时间越短的节点,越有可能被选为计算节点。
网络延迟和带宽:调度器会考虑网络延迟和数据传输带宽等因素,以确保数据传输和通信的效率和稳定性。
容错能力:调度器还会考虑节点之间的负载均衡和容错能力等因素,以确保整个查询任务的性能和可靠性。例如,调度器可能会选择多个计算节点来处理同一个Split,以提高计算性能和容错能力。 Presto的调度器是动态的,会根据集群的负载和资源情况等因素实时调整计算节点的选择策略,以最大程度地提高查询的性能和效率。
动态编译执行计划
Presto会将执行计划中的ScanFilterAndProjectOperator和FilterAndProjectOperator动态编译为字节码(Bytecode),并交给JIT去编译为本地机器码(Native Code)。Presto也使用了Google Guava提供的LoadingCache缓存生成的Byte Code。Presto将生成的本地机器码加载到内存中,并使用它来执行操作符。
Presto采用了延迟编译的策略,即只有在需要执行操作符时才会进行编译。这种策略可以减少不必要的编译开销,并提高查询的性能。
优化内存数据结构
使用 Slice 进行内存操作,Slice使用Unsafe#copyMemory实现了高效的内存拷贝。Slice仓库:https://github.com/airlift/slice。写性能提高20%-30%。(https://engineering.fb.com/2014/04/10/core-data/scaling-the-facebook-data-warehouse-to-300-pb/)
近似查询算法
为了加快avg、count distinct、percentile等聚合函数的查询速度,与BlinkDB合作引入了一些近似查询函数approx_avg、approx_distinct、approx_percentile。approx_distinct使用HyperLogLog Counting算法实现。
GC控制
Presto团队在使用hotspot java7时发现了一个JIT的BUG,当代码缓存快要达到上限时,JIT可能会停止工作,从而无法将使用频率高的代码动态编译为native代码。
Presto团队使用了一个比较Hack的方法去解决这个问题,增加一个线程在代码缓存达到70%以上时进行显式GC,使得已经加载的Class从perm中移除,避免JIT无法正常工作的BUG。
Reference
https://github.com/airlift/slice
https://engineering.fb.com/2014/04/10/core-data/scaling-the-facebook-data-warehouse-to-300-pb/
9.6 - PostgreSQL
Introduction
PostgreSQL
9.6.1 - PostgreSQL基础使用入门
简介
PostgreSQL是一个功能非常强大的、源代码开放的客户/服务器关系型数据库管理系统(RDBMS),在BSD许可证下发型。PostgreSQL简称PG。
Slogan 是“世界上最先进的开源关系型数据库”。“开源界的Oracle”。
PostgreSQL 官网:https://www.postgresql.org/
PostgreSQL 中文社区:http://www.postgres.cn/v2/home
另外再数据库排行榜中也可以看到 PG 的受欢迎程度。
全球数据库排行:https://db-engines.com/en/ranking
国产数据库排行:https://www.modb.pro/dbrank
历史
PostgreSQL 最初设想于1986年,当时被叫做Berkley Postgres Project。该项目一直到1994年都处于演进和修改中,直到开发人员Andrew Yu和Jolly Chen在Postgres中添加了一个SQL(Structured Query Language,结构化查询语言)翻译程序,该版本叫做Postgres95,在开放源代码社区发放。
1996年,再次对Postgres95做了较大的改动,并将其作为PostgresSQL6.0版发布。该版本的Postgres提高了后端的速度,包括增强型SQL92标准以及重要的后端特性(包括子选择、默认值、约束和触发器)。
2005年,发布8.0版本,开始支持Windows系统环境。
2010年9月20日发布了PostgreSQL 9.0,大大增强了复制的功能(replication),比如增加了流复制功能(stream replicaction)和HOT standby功能。
PostgreSQL 9.0:支持64位Windows系统,异步流数据复制、Hot Standby。
PostgreSQL 9.1:支持数据同步复制,unlogged tables、serializable snapshot isolation、FDW外部表等。此版本后,开始得到中国多个行业用户的关注,开始应用于电信、保险、制造业等边缘系统。
2019年,PostgreSQL 12 版本发布,这也是目前生产环境主流的版本。2021年,PostgreSQL 14 版本发布。
社区
纯社区
纯社区,没有被商业公司控制

最终用户都希望社区长久,期望可以享受免费的、可持续发展、开源的、不被任何商业公司或国家控制的企业级数据库,不靠数据库赚钱。
云厂商基于PG的好处:
- 免去自己培养生态
- 避免重复造轮子
- PG代码基础非常不错
- 防止其他厂商控制PG失去市场主导能力(赞助商:AWS/google/IBM/微软)
开源许可独特性
PostgreSQL遵守BSD许可证发型,使得开发者们得以获取源代码进一步开发系统。
BSD许可协议(Berkeley Software Distribution license)是自由软件中使用最广泛的许可协议之一。BSD遵照该许可证来发布,BSD许可证比较宽松,甚至跟共有领域更为接近。BSD的后续版本可以选择要继续是BSD或其他自由软件条款或封闭软件等。
众所周知,MySQL倍Oracle所控制,MySQL同时使用了GPL和一种商业许可(称为双重许可)。
GPL(General Public License)是公共许可,遵循了GPL的软件是公共的。如果某软件使用GPL软件,那么该软件也需要开源,如果不开源就不能使用GPL软件,这和是否把该软件商用与否没有关系。
如果无法满足GPL,就需要获得商业许可,通过与Oracle公司联系,指定解决方案,受Oracle公司约束。
同为开源软件,PostgreSQL源码使用自由友好,商业应用不受任何公司实体所控制,而MySQL在一定程度上有所限制。
与MySQL比较
PostgreSQL的优势:
在SQL的标准实现上要比MySQL完善,而且功能实现比较严谨
对表链接支持较完整,优化器功能较完整,支持的索引类很多,复杂查询能力较强
PG主表采用堆表存放,MySQL采用索引组织表,能够支持MySQL更大的数据量
PG主备复制属于物理复制,相对MySQL基于binlog的逻辑复制,数据的一致性更加可靠,复制性能更高,对主机性能影响更小
PostgreSQL支持JSON和其他NoSQL功能,如本机XML支持和使用HSTORE的键值对。还支持索引JSON数据以加快访问速度,特别是10版本JSONB更强大
PostgreSQL基于BSD协议完全免费,在其基础上修改然后商用也是可以的,使得PG不会被其它公司控制。而MySQL主要被Oracle公司控制 PostgreSQL的劣势:
innodb 基于回滚段实现的 MVCC 机制,相对 PG 新老数据一起存放的基于 XID 的 MVCC 机制是更好的。新老数据一起存放需要定时触发 VACUUM,会带来多余的IO和数据库对象加锁开销,引起整体并发能力下降。且 VACUUM 清理不及时回引发数据膨胀。
MySQL采用索引组织表,这种存储方式适合基于主键匹配的查询、删改操作,但对表结构设计存在约束。
MySQL优化器较简单,系统表、运算符、数据类型的实现都很精简,非常适合简单的查询操作。
MySQL相比PG在国内的流行度更高。
MySQL存储引擎插件化机制,使得应用场景更加广泛,如innodb适合事务处理场景外,myisam适合静态数据的查询场景。 从应用场景来说,PG更加适合严格的企业应用场景(比如金融、电信、ERP、CRM),且其 json、jsonb、hstore 等数据格式,特别适用于一些大数据格式的分析。而 MySQL 更加适合业务逻辑相对简单、数据可靠性要求较低的互联网场景(如google、alibaba等),当然MySQL在innodb引擎的大力发展下功能表现良好。
特性
PG拥有众多开放特性:
- 开放的数据类型接口:除了传统数据库支持的类型,还支持GIS,JSON,RANGE,IP,ISBN,图像特征值,化学,DNA等等扩展的类型,还可以根据实际业务扩展更多的类型。
- 开放的操作符接口:不仅支持常见的类型操作符,还支持扩展的操作符,例如距离符,逻辑并、交、差符号,图像相似符号,几何计算符号等等扩展的符号,用户还可以根据实际业务扩展更多的操作符。
- 开放的外部数据源接口:PG支持丰富的外部数据源,例如可以通过FDW读写mysql, redis, mongo, oracle, sqlserver, hive, www, hbase, ldap等等,只要你能想到的数据源都可以通过FDW接口读写。
- 开放的语言接口:使得PG支持几乎地球上所有的编程语言作为数据库的函数、存储过程语言,例如plpython , plperl , pljava , plR , plCUDA , plshell等等。用户可以通过language handler扩展PG的语言支持。
- 开放的索引接口:使得PG支持非常丰富的索引方法,例如btree , hash , gin , gist , sp-gist , brin , bloom , rum , zombodb , bitmap (greenplum extend),用户可以根据不同的数据类型,以及查询的场景,选择不同的索引。
- PG内部还支持BitmapAnd, BitmapOr的优化方法,可以合并多个索引的扫描操作,从而提升多个索引数据访问的效率。
安装
下载
官网下载地址:https://www.postgresql.org/download/
安装
Windows
直接下载安装,安装过程中设置密码,用户名默认为 postgres。
Linux
参考官方文档安装,添加源、执行脚本等等,不同发型版本有些许差别。
如 Debian 系统安装:https://www.postgresql.org/download/linux/debian/
| |
如 CentOS 系统安装:https://www.postgresql.org/download/linux/redhat/
| |
使用
Windows
界面工具:
官方自带的 pgAdmin 工具。
命令行工具:
官方自带的 SQL Shell (psql) 终端工具。
三方界面工具:
如Navicat,PG默认端口为 5432。
注意:PG默认不允许远程连接,需要修改安装目录下 data/pg_hba 文件配置:
| |
然后重启服务。
windows打开服务管理界面(可通过services.msc),重启 postgresql-x64-xx。 注意防火墙也需要关闭。
Linux
初始化数据库:
| |
启动服务:
| |
修改密码:
安装成功后会默认创建一个名为 postgres 的 Linux 用户,初始化数据库后会有名为 postgres 的数据库(相当于MySQL中的名为mysql的数据库)。
进入PG命令行:
| |
配置远程访问:
1、开放端口
| |
2、修改IP绑定
| |
3、允许所有IP访问
| |
4、重启服务
| |
数据类型
创建表时必须使用数据类型,PG主要有以下几类数据类型:
- 数值数据类型
- smallint:2字节
- integer:4字节
- bigint:8字节
- decimal:可变长
- numeric:可变长
- real:4字节
- double:8字节
- 字符串数据类型
- char
- varchar
- text
- 日期/时间数据类型
- timestamp:日期和时间
- date:日期
- time:时间
- 数组类型
- int[]
- text[]
- int[][]
- json/jsonb类型
- json
- jsonb
- xml类型
- 货币类型
- money
- 布尔
- boolean
- 空间几何类型
- point
- line
- box
- path
- circle
- 网络地址类型
- inet
- macaddr
- 位串
- bit
- uuid类型
- 复合类型
- CREATE TYPE complex AS(a int, b int)
- 范围类型
- int4range
- numrange
- daterange
基本使用
控制台常用命令
| |
登录
| |
数据库操作
| |
数据库表操作
初级用法:
| |
增加属性:
| |
Schema
PG模式(SCHEMA)可以看着是一个表的集合。
一个模式可以包含视图、索引、数据类型、函数和操作符等。
相同的对象名称可以被用于不同的模式中不会出现冲突,例如 schema1 和 schema2 都可以包含名为 table1 的表。
使用模式的优势:
- 允许多个用户使用一个数据库且互相不干扰
- 将数据库对象组织成逻辑组以便更容易管理
- 第三方应用的对象可以放在独立的模式中,不会与其他对象的名称发生冲突 模式类似于操作系统层的目录,但是模式不能嵌套。
| |
备份数据库
单数据库
PG提供了 pg_dump 来简化备份单个数据库的过程。前提:用户必须有数据库的读取权限:
| |
备份格式:
- .bak:压缩二进制格式
- .sql:明文转储
- .tar:
多数据库
pg_dump 一次只创建一个数据库的备份,不会存储有关数据库角色或其他群集范围配置的信息。 要存储此信息并同时备份所有数据库,可以使用 pg_dumpall。
| |
Demo:
| |
用户操作
Expand/Collapse Code Block
| |
pg_hba.conf配置中的第一项设置表示:本地用户通过unix socket登陆时,使用peer方式认证。
| |
在peer方式中,client必须和PG在同一台机器上。peer使用PostgreSQL所在的操作系统上的用户登陆,只要当前系统用户和登录PG的用户名相同就可以正常登录。
部署PG后,切换到系统的postgres用户,直接执行psql就能进入PG就是这个原因。
| |
注意:peer不是常用的方式,最常用的方式是通过密码远程登陆。
角色管理
PG中没有区分用户和角色, CREATE USER 为 CREATE ROLE 的别名,两个命令几乎相同。唯一的区别是前者创建的用户默认带有 login 属性,而后者创建的默认不带。
创建用户/角色
Expand/Collapse Code Block
| |
角色属性
| 属性 | 说明 |
|---|---|
| login | 只有具有 LOGIN 属性的角色可以用做数据库连接的初始角色名。 |
| superuser | 数据库超级用户 |
| createdb | 创建数据库权限 |
| createrole | 允许其创建或删除其他普通的用户角色(超级用户除外) |
| replication | 做流复制的时候用到的一个用户属性,一般单独设定。 |
| password | 在登录时要求指定密码时才会起作用,比如md5或者password模式,跟客户端的连接认证方式有关 |
| inherit | 用户组对组员的一个继承标志,成员可以继承用户组的权限特性 |
创建用户赋予角色属性
创建角色 wangwu 并赋予其 CREATEDB 的权限:
| |
创建角色 zhaoliu 并赋予其创建数据库及带有密码登录的属性:
| |
测试 zhaoliu 角色:
| |
为用户赋予权限
给存在的用户赋予各种权限。
赋予 wangwu 登录权限:
| |
赋予 zhaoliu 创建角色的权限:
| |
Reference
9.7 - 图
Introduction
图
9.7.1 - 图数据库基本介绍
简介
简介
世间万物都是存在普遍联系的,人们的生产活动中,时时刻刻都在产生大量的数据,形成了一个个庞大且复杂的关系网。传统的数据库对于这种庞大复杂的关系网无法高效准确地表达,因此诞生了图数据库,它可以更加高效、准确地表达出这种关联关系,且有助于进一步挖掘出更深层次的关系。
场景
社交网络
精准营销、好友推荐、舆情追踪。
金融
信用卡反欺诈、资金流向识别。
零售
用户360画像、商品实时推荐、反薅羊毛等。
电力
电网调度仿真、故障分析、电碳因子计算。
电信
防骚扰、防诈骗。
政企
道路规划、智能交通、疫情精准防控。
制造
供应链管理、物流优化、产品溯源。
网络安全
攻击溯源、调用链分析。
挑战
- 数据规模大
- 关联跳数深
- 实时要求高
基本概念
核心目标
核心语义
数据规模大、关联跳数深、实时要求高的场景下,完成一个图查询或者图分析,核心的操作是:邻居的迭代遍历。
关系型数据库中,边(即实体之间的关系)不是直接存储的,而是以外键的形式来表示。它的问题在于性能无法满足要求,即使在主键和外键上都建立索引,在面临大规模数据和查询跳数较深时,索引对性能的提升也非常有限。
查询性能对比
免索引邻接
定义
免索引邻接(Index Free Adjacency)
写入时:保证一个点和它直接相连的边总是存储在一起
查询时:迭代遍历一个点的所有邻居可以直接进行,而不需要依赖其他数据结构
图数据库存储的核心目标是实现免索引邻接。
写入时,免索引邻接可以保证一个点和与它相邻的边总是存储在一起。因此查询时迭代遍历一个点的所有邻居就可以直接进行,而不需要依赖其它索引类的数据结构。与全局索引对比,查询操作的时间复杂度是 O(1) vs O(log(n))。
因为免索引邻接迭代一个点的所有邻居的时间,就是这个点的邻居数量乘以O(1),仅与这个点的邻居数量有关,而与整个图中全体的点边数量无关。
而使用全局索引,那么每次定位都需要一个 O(log(n)) 的时间复杂度。n 指参与全局索引的数据规模,可以理解为整体的边数量。因此迭代一个点的所有邻接时间就是这个点的邻居数量乘以 O(log(n))。
从算法的时间度复杂度上看,O(log(n)) 已经非常快,当处理巨大规模数据量时,这个值也会非常可观。但是需要注意的是,log(n)的值会随着n的增大而不断增大。假设点只有一个邻居,使用全局索引,会随着图中的总边数增加而越来越慢。如果具备了免索引邻接的能力,那么获取这个邻居的时间就是一个恒定值,而与全局的图规模无关。这个特点会带来巨大的性能提升。所以可以明确,图数据库存储的核心目标就是实现免索引邻接。
技术方案
数据模型
点(Vertex)
边(Edge)
边的方向
数组存储
数组存储结构图

最直接的方法就是用一个数组,把每一个点上面的所有边,按照顺序一起存储。点文件就是一系列的点组成,每个点的存储,包括点的ID、META信息,以及这个点的一系列属性。每个边文件中,按照起始点的顺序存储点上对应的边。每条边的存储包括终止点ID、META信息,以及边的属性。META信息包括点边类型、边方向、实现事务的额外字段等。在这个存储里,可以直接从起始点遍历所有的边数据,读取性能非常高。
变长数组
存在的问题:变长数组。
可能有很多因素导致。如:两个点的属性数量不同、属性本身内容不同、属性值是字符串也是变长的(属性长度不一样导致每个点的存储空间变长)。点文件和边文件都会面临变长数组的问题。
解决思路:
1、用额外的 offset 来记住存储位置
2、预先划分好部分预留空间
数组存储结构图(处理变长)

链表存储
链表存储结构图

链表的存储方式中,点文件和边文件里面存储的都是ID,每个ID都是固定长度的,通过ID可以计算偏移量位置,通过偏移量位置直接读取数据。因为它能够通过位置计算ID,偏移量和ID是一一对应的,所以每个点也不用保存自身的ID。
边迭代
链表存储结构图(迭代边示意)

边迭代的过程:
首先从点A出发,在点文件中找到首个边ID:α,去边文件中找到α对应的偏移量,就能把整条边数据读出来。边数据里,有起始点和终止点,比如这条边的起始点A、终止点B。下一条边的偏移量是θ,那么就再找到θ的位置。θ边读出来,它是从起始点C到终止点A。这时候点A是处于终止点的位置上,我们找对应终止点的下一条边,是ω。然后再找ω的偏移量,读出来,是一个A到D的边,A在起始点的位置上,下一条边是NULL,迭代遍历结束。我们可以看到,链表存储的方式很好地解决了变长的问题。
随机读操作
链表存储下,每次迭代时offset的位置是随机的,不是连续存储的,因此会有大量的随机读操作。而磁盘对随机读操作是很不友好的,也就是说虽然时间复杂度是O(1),但是这个O(1)的单位是磁盘随机读的时间。而前面数组方案中的O(1)的单位是磁盘顺序读的时间,这两者在性能上差别非常大。所以使用链表的存储方法,非常依赖一个高效实现的缓存机制。如果我们能把这个存储结构在内存中缓存起来,那么在内存中进行随机访问的性能会非常高。
LSM树存储
LSM树存储示意图

LSM树存储是一种基于顺序写盘、多层结构的KV存储。
上图展示了LSM树读写操作的核心流程:
在写请求时,直接写入内存中的MemTable。如果这个MemTable没满,这个写请求就直接返回了,所以写请求性能是很高的。当这个MemTable满了的时候,把它变成Immutable MemTable,同时生成一个新的MemTable供后续的写请求使用。同时,把Immutable MemTable的内容写到磁盘上,形成SSTable文件。内存中的MemTable和Immutable MemTable都是按Key排序的,所以SSTable也是按Key排序的。SSTable文件是分层组织的,直接从内存中写出来的是第0层,当第0层数据达到一定大小之后,就把它跟第1层合并,类似归并排序。合并出来的第1层文件也是顺序写排的,当第1层达到一定大小也会继续和下层合并,以此类推。在合并的时候,会清除重复的数据或者被删除的数据。
在读取请求时,首先去内存中的MemTable查找,查到就直接返回。没查到就去第0层的文件中查找,第0层没有再到第1层,这样逐层查找。
Key设计
关键点:合理地设计边的Key,使一个点的所有边在排序后是相邻的。

因为在SSTable文件的存储中,key是有序排列的,所以我们只要通过LSM树实现免索引邻接的能力。关键点在于合理地设计边的Key,要让一个点的所有边在排序后是相邻的。
上图中例1,只要把边Key的最高位放起始点ID,那么排序之后,从这个起点出发的边自然就会排在一起。这里还可以有一个编号字段,加入编号字段就可以支持在两点之间的同类型多条边的共存。因为LSM树是KV结构,所以如果只有起始点、终止点和META的话,那么两点之间同类型的边只有一个Key,所以只能存一条。对于像转账交易、访问记录这样具有事件性质的边来说,两点之间肯定会有多条同类型的边,在这样的场景下,这个能力就是非常重要的。
在某些场景下边的Key也可以不以起始点开始,比如例2的场景下。可以先放边的类型,再放起始点ID。这样做的目的是为了能够通过边类型直接做分片。因为在分布式环境下,做这样的分片可能会有更好的性能。这样虽然一个点的所有边是分散存储的,但是一个点某个类型的所有边还是顺序存储在一起的。如果业务场景是边查群总是按照类型分别迭代的,那么它也能提供很好的免索引邻接的能力。
难点:
1、读性能
2、Compaction的影响
3、依赖第三方存储
首先,SSTable文件是分层的,在查询时的最坏情况下,需要找遍所有层才能知道找得到或者找不到,因此读性能是没有直接使用数组的方式那么高的。另外,Compaction对它的影响是很大的,Compaction是个后台操作,会占用大量的磁盘IO,势必对前台读写性能造成影响。第三是,使用LSM方案通常都要依赖第三方存储,对于一些特定的需求,必须要改动第三方存储项目才能实现。
优化之路

可以看到,几种常见的实现免索引邻接的存储方式,都不是一劳永逸的方案,而是各有各的优势和短板:
通过数组的方式读取速度快,但写入速度慢;通过LSM树的方式写入速度快,但是读取速度慢。通过链表的方式,读取和写入的速度都不占优,但却是灵活性最高的方式。
在实际实现一个图数据库的过程中,要根据我们的设计理念去做取舍。
实现一个完整的图数据库产品,还有很多功能和性能的问题需要考虑。比如图数据库特有的反向边一致性的问题,还有分布式条件下怎样做分区分片,怎样处理分布式事务,是否支持mvcc快照,实时副本怎么做,WAL怎么做,属性索引怎么做,以及是否支持数据过期等。这些都是一个成熟的图数据库产品需要解决的问题。解决这些问题的同时,也要兼顾底层存储的特性。
图算法
路径搜索类
路径搜索是搜索途中节点通过边建立的直接或间接的联系。
中心性分析类
中心新分析是指分析特定节点在途中的重要程度及其影响力。
社区发现类
社区发现意在发现图中联系更紧密的群体结构。
查询语言
Germlin Cypher
应用实践
开源图数据库杂谈
目前比较成熟的大部分都是面对传统行业较小的数据集和较低的访问吞吐场景,开源的 Neo4j 是单机架构;因此,在互联网场景下,通常都是基于已有的基础设施定制系统:比如 Facebook 基于 MySQL 系统封装了 Social Graph 系统 TAO,几乎承载了 Facebook 所有数据逻辑;Linkedln 在 KV 之上构建了 Social Graph 服务;微博是基于 Redis 构建了粉丝和关注关系。
Neo4j
简介
Neo4j是图数据库中一个主要代表,其开源,且用Java实现(需安装JDK)。经过几年的发展,已经可以用于生产环境。其有两种运行方式,一种是服务的方式,对外提供REST接口;另外一种是嵌入式模式,数据以文件的形式存放在本地,可以直接对本地文件进行操作。
Neo4j相关特性
数据模型
Neo4j被称为property graph,除了顶点(Node)和边(Relationship,其包含一个类型),还有一种重要的部分——属性。无论是顶点还是边,都可以有任意多的属性。属性的存放类似于一个hashmap,key为一个字符串,而value必须是Java基本类型、或者是基本类型数组,比如说String、int或者int[]都是合法的。
索引
Neo4j支持索引,其内部实际上通过Lucene实现。
事务
Neo4j完整支持事务,即满足ACID性质。
Neo4j优缺点
优点:
数据的插入,查询操作很直观,不用再像之前要考虑各个表之间的关系。
提供的图搜索和图遍历方法很方便,速度也是比较快的。
更快的数据库操作。当然,有一个前提条件,那就是数据量较大,在MySql中存储的话需要许多表,并且表之间联系较多(即有不少的操作需要join表)。 缺点:
当数据过大时插入速度可能会越来越慢。.
超大节点。当有一个节点的边非常多时(常见于大V),有关这个节点的操作的速度将大大下降。这个问题很早就有了,官方也说过会处理,然而现在仍然不能让人满意。
提高数据库速度的常用方法就是多分配内存,然而看了官方操作手册,貌似无法直接设置数据库内存占用量,而是需要计算后为其”预留“内存… 注:鉴于其明显的优缺点,Neo4j适合存储”修改较少,查询较多,没有超大节点“的图数据。
ByteGraph
简介
字节跳动的 Graph 在线存储场景, 其需求也是有自身特点的,可以总结为:
- 海量数据存储:百亿点、万亿边的数据规模;并且图符合幂律分布,比如少量大 V 粉丝达到几千万;
- 海量吞吐:最大集群 QPS 达到数千万;
- 低延迟:要求访问延迟 pct99 需要限制在毫秒级;
- 读多写少:读流量是写流量的接近百倍之多;
- 轻量查询多,重量查询少:90%查询是图上二度以内查询;
- 容灾架构演进:要能支持字节跳动城域网、广域网、洲际网络之间主备容灾、异地多活等不同容灾部署方案。 面对字节跳动世界级的海量数据和海量并发请求,用万亿级分布式存储、千万高并发、低延迟、稳定可控这三个条件一起去筛选,业界在线上被验证稳定可信赖的开源图存储系统基本没有满足的了。
在 18 年 8 月份,开始从第一行代码开始踏上图数据库的漫漫征程,从解决一个最核心的抖音社交关系问题入手,逐渐演变为支持有向属性图数据模型、支持写入原子性、部分 Gremlin 图查询语言的通用图数据库系统,在公司所有产品体系落地称之为 ByteGraph。
场景
- 记录关注关系A关注B
- 查询A关注的且关注了C的所有用户
- 查询A的好友的好友(二度关系)
系统架构
下面这张图展示了 ByteGraph 的内部架构,其中 bg 是 ByteGraph 的缩写。
就像 MySQL 通常可以分为 SQL 层和引擎层两层一样,ByteGraph 自上而下分为查询层 (bgdb)、存储/事务引擎层(bgkv)、磁盘存储层三层,每层都是由多个进程实例组成。其中 bgdb 层与 bgkv 层混合部署,磁盘存储层独立部署,我们详细介绍每一层的关键设计。

查询层(bgdb)
bgdb 层和 MySQL 的 SQL 层一样,主要工作是做读写请求的解析和处理;其中,所谓“处理”可以分为以下三个步骤:
- 将客户端发来的 Gremlin 查询语句做语法解析,生成执行计划;
- 并根据一定的路由规则(例如一致性哈希)找到目标数据所在的存储节点(bgkv),将执行计划中的读写请求发送给 多个 bgkv;
- 将 bgkv 读写结果汇总以及过滤处理,得到最终结果,返回给客户端。 bgdb 层没有状态,可以水平扩容,用 Go 语言开发。

存储/事务引擎层(bgkv)
bgkv 层是由多个进程实例组成,每个实例管理整个集群数据的一个子集(shard / partition)。
bgkv 层的实现和功能有点类似内存数据库,提供高性能的数据读写功能,其特点是:
接口不同:只提供点边读写接口;支持算子下推:通过把计算(算子)移动到存储(bgkv)上,能够有效提升读性能;举例:比如某个大 V 最近一年一直在涨粉,bgkv 支持查询最近的 100 个粉丝,则不必读出所有的百万粉丝。
缓存存储有机结合:其作为 KV store 的缓存层,提供缓存管理的功能,支持缓存加载、换出、缓存和磁盘同步异步 sync 等复杂功能。
从上述描述可以看出,bgkv 的性能和内存使用效率是非常关键的,因此采用 C++ 编写。
磁盘存储层(KV Cluster)
为了能够提供海量存储空间和较高的可靠性、可用性,数据必须最终落入磁盘,我们底层存储是选择了公司自研的分布式 KV store。
问题:如何把动辄百万粉丝的图数据存储在KV数据库中?
在字节跳动的业务场景中,存在很多访问热度和“数据密度”极高的场景,比如抖音的大 V、热门的文章等,其粉丝数或者点赞数会超过千万级别;但作为 KV store,希望业务方的 KV 对的大小(Byte 数)是控制在 KB 量级的,且最好是大小均匀的:对于太大的 value,是会瞬间打满 I/O 路径的,无法保证线上稳定性;对于特别小的 value,则存储效率比较低。事实上,数据大小不均匀这个问题困扰了很多业务团队,在线上也会经常爆出事故。
对于一个有千万粉丝的抖音大 V,相当于图中的某个点有千万条边的出度,不仅要能存储下来,而且要能满足线上毫秒级的增删查改,ByteGraph 是如何解决这个问题?
思路其实很简单,总结来说,就是采用灵活的边聚合方式,使得 KV store 中的 value 大小是均匀的,具体可以用以下四条来描述:
- 一个点(Vertex)和其所有相连的边组成了一数据组(Group);不同的起点和及其终点是属于不同的 Group,是存储在不同的 KV 对的;比如用户 A 的粉丝和用户 B 的粉丝,就是分成不同 KV 存储;
- 对于某一个点的及其出边,当出度数量比较小(KB 级别),将其所有出度即所有终点序列化为一个 KV 对,我们称之为一级存储方式(后面会展开描述);
- 当一个点的出度逐渐增多,比如一个普通用户逐渐成长为抖音大 V,我们则采用分布式 B-Tree 组织这百万粉丝,我们称之为二级存储;
- 一级存储和二级存储之间可以在线并发安全的互相切换; 一级存储
一级存储格式中,只有一个 KV 对,key 和 value 的编码:
| |
二级存储(点的出度大于阈值) 如果一个大 V 疯狂涨粉,则存储粉丝的 value 就会越来越大,解决这个问题的思路也很朴素:拆成多个 KV 对。
ByteGraph 的方式就是把所有出度和终点拆成多个 KV 对,所有 KV 对形成一棵逻辑上的分布式 B-Tree,之所以说“逻辑上的”,是因为树中的节点关系是靠 KV 中 key 来指向的,并非内存指针; B-Tree 是分布式的,是指构成这棵树的各级节点是分布在集群多个实例上的,并不是单机索引关系。具体关系如下图所示:

其中,整棵 B-Tree 由多组 KV 对组成,按照关系可以分为三种数据:
- 根节点:根节点本质是一个 KV 系统中的一个 key,其编码方式和一级存储中的 key 相同
- Meta 数据:
- Meta 数据本质是一个 KV 中的 value,和根节点组成了 KV 对;
- Meta 内部存储了多个 PartKey,其中每个 PartKey 都是一个 KV 对中的 key,其对应的 value 数据就是下面介绍的 Part 数据;
- Part 数据
- 对于二级存储格式,存在多个 Part,每个 Part 存储部分出边的属性和终点 ID
- 每个 Part 都是一个 KV 对的 value,其对应的 key 存储在 Meta 中。 从上述描述可以看出,对于一个出度很多的点和其边的数据(比如大 V 和其粉丝),在 ByteGraph 中,是存储为多个 KV 的,面对增删查改的需求,都需要在 B-Tree 上做二分查找。相比于一条边一个 KV 对或者所有边存储成一个 KV 对的方式,B-Tree 的组织方式能够有效的在读放大和写放大之间做一些动态调整。
但在实际业务场景下,粉丝会处于动态变化之中:新诞生的大 V 会快速新增粉丝,有些大 V 会持续掉粉;因此,存储方式会在一级存储和二级存储之间转换,并且 B-Tree 会持续的分裂或者合并;这就会引发分布式的并发增删查改以及分裂合并等复杂的问题,有机会可以再单独分享下这个有趣的设计。
ByteGraph 和 KV store 的关系,类似文件系统和块设备的关系,块设备负责将存储资源池化并提供 Low Level 的读写接口,文件系统在块设备上把元数据和数据组织成各种树的索引结构,并封装丰富的 POSIX 接口,便于外部使用。
一些问题深入讨论
热点数据读写解决
热点数据在字节跳动的线上业务中广泛存在:热点视频、热点文章、大 V 用户、热点广告等等;热点数据可能会出现瞬时出现大量读写。ByteGraph 在线上业务的实践中,打磨出一整套应对性方案。
热点读 热点读的场景随处可见,比如线上实际场景:某个热点视频被频繁刷新,查看点赞数量等。在这种场景下,意味着访问有很强的数据局部性,缓存命中率会很高,因此,我们设计实现了多级的 Query Cache 机制以及热点请求转发机制;在 bgdb 查询层缓存查询结果, bgdb 单节点缓存命中读性能 20w QPS 以上,而且多个 bgdb 可以并发处理同一个热点的读请求,则系统整体应对热点度的“弹性”是非常充足的。
热点写 热点读和热点写通常是相伴而生的,热点写的例子也是随处可见,比如:热点新闻被疯狂转发, 热点视频被疯狂点赞等等。对于数据库而言,热点写入导致的性能退化的背后原因通常有两个:行锁冲突高或者磁盘写入 IOPS 被打满,我们分别来分析:
- 行锁冲突高:目前 ByteGraph 是单行事务模型,只有内存结构锁,这个锁的并发量是每秒千万级,基本不会构成写入瓶颈;
- 磁盘 IOPS 被打满:
- IOPS(I/O Count Per Second)的概念:磁盘每秒的写入请求数量是有上限的,不同型号的固态硬盘的 IOPS 各异,但都有一个上限,当上游写入流量超过这个阈值时候,请求就会排队,造成整个数据通路堵塞,延迟就会呈现指数上涨最终服务变成不可用。
- Group Commit 解决方案:Group Commit 是数据库中的一个成熟的技术方案,简单来讲,就是多个写请求在 bgkv 内存中汇聚起来,聚成一个 Batch 写入 KV store,则对外体现的写入速率就是 BatchSize * IOPS。

对于某个独立数据源来说,一般热点写的请求比热点读会少很多,一般不会超过 10K QPS,目前 ByteGraph 线上还没有出现过热点写问题问题。
图的索引
就像关系型数据库一样,图数据库也可以构建索引。默认情况下,对于同一个起点,我们会采用边上的属性(时间戳)作为主键索引;但为了加速查询,我们也支持其他元素(终点、其他属性)来构建二级的聚簇索引,这样很多查找就从全部遍历优化成了二分查找,使得查询速度大幅提升。
ByteGraph 默认按照边上的时间戳(ts)来排序存储,因此对于以下请求,查询效率很高:
查询最近的若干个点赞查询某个指定时间范围窗口内加的好友
方向的索引可能有些费解,举个例子说明下:给定两个用户来查询是否存在粉丝关系,其中一个用户是大 V,另一个是普通用户,大 V 的粉丝可达千万,但普通用户的关注者一般不会很多;因此,如果用普通用户作为起点大 V 作为终点,查询代价就会低很多。其实,很多场景下,我们还需要用户能够根据任意一个属性来构建索引,这个也是我们正在支持的重要功能之一。
未来探索
过去的一年半时间里,ByteGraph 都是在有限的人力情况下,优先满足业务需求,在系统能力构建方面还是有些薄弱的,有大量问题都需要在未来突破解决:
从图存储到图数据库:对于一个数据库系统,是否支持 ACID 的事务,是一个核心问题,目前 ByteGraph 只解决了原子性和一致性,对于最复杂的隔离性还完全没有触碰,这是一个非常复杂的问题;另外,中国信通院发布了国内图数据库功能白皮书,以此标准,如果想做好一个功能完备的“数据库”系统,我们面对的还是星辰大海;标准的图查询语言:目前,图数据库的查询语言业界还未形成标准(GQL 即将在 2020 年发布),ByteGraph 选择 Apache、AWS 、阿里云的 Gremlin 语言体系,但目前也只是支持了一个子集,更多的语法支持、更深入的查询优化还未开展;Cloud Native 存储架构演进:现在 ByteGraph 还是构建与 KV 存储之上,独占物理机全部资源;从资源弹性部署、运维托管等角度是否有其他架构演进的探索可能,从查询到事务再到磁盘存储是否有深度垂直整合优化的空间,也是一个没有被回答的问题;现在 ByteGraph 是在 OLTP 场景下承载了大量线上数据,这些数据同时也会应用到推荐、风控等复杂分析和图计算场景,如何把 TP 和轻量 AP 查询融合在一起,具备部分 HTAP 能力,也是一个空间广阔的蓝海领域。
Galaxybase
特点
Galaxybase是一个国产高性能分布式图数据库。它具有如下特点:
- 速度快:原生分布式并行图存储,毫秒级完成传统方案无法实现的深链分析, 较同类技术百倍提升。
- 高扩展:完全分布式架构,动态在线扩容,高效支持万亿级超级大图。
- 实时计算:内置丰富分布式图算法、无ETL实现实时图分析。
- 高效数据压缩:优化资源利用,节省硬件和维护成本。
- 全自主可控、兼容国际开源生态与国产底层硬件。
技术方案

- 自研分布式原生图存储,不依赖第三方存储引擎
- 使用数据分片,支持热备
- 支持动态压缩,节省存储空间
性能优势
和中山大学携手共建的一个国家重点研发图计算项目。它依托国家超算广州中心的环境,仅用50台机器的集群就完成了5万亿规模交易数据的智能挖掘系统,实现了当前全球商业图数据支持的最大规模图数据处理。打破了之前用1000台机器集群创造了1.2万亿规模的大图处理的世界记录。我们涵盖的出入度,最大有超过1000万的超级节点。六跳深链查询平均耗时仅6.7秒。
优异的查询性能

Galaxybase图数据库具有优异的查询性能。上图是LDBC-SNB官方对Galaxybase进行测试的结果。测试由国外权威机构进行,首先进行了结果正确性验证确保图数据库返回正确结果;随后进行了系统稳定性、可用性、事务支持性和可恢复性验证,均达到官方标准;最后进行了各项性能测试。Galaxybase表现优异,达到国际领先水平。在使用完全相同系统配置前置下,Galaxybase较LDBC之前公布的最高记录吞吐量提升了70%,平均查询性能达6倍以上提升,最高查询性能提升72倍。
丰富的图算法支持

Galaxybase也提供了丰富的算法支持,提供了包括像图遍历、路径发现、中心性分析、社群发现、相似度分析、子图模式匹配等几个大类的上百种图算法。不久前率先通过了信通院图计算平台的一个产品基础能力评测,涉及五个能力域,34个能力项的评测,全方位覆盖了图平台的基本能力、兼容能力、管理能力、高可用和扩展能力。
Reference
9.8 - LevelDB
Introduction
LevelDB
9.8.1 - LevelDB-01基本介绍
简介
简介
LevelDB 是一个key/value型的单机存储引擎,由google开发,并宣布在BSD许可下开放源代码。它是 leveling+ 分区实现的LSM典型代表。
特性
- key、value支持任意的byte类型数组,不单单支持字符串
- LevelDB 是一个持久化存储的KV系统,将大部分数据存储到磁盘上
- 按照记录key值顺序存储数据,并且LevleDb支持按照用户定义的比较函数进行排序
- 操作接口简单,包括写/读记录以及删除记录,也支持针对多条操作的原子批量操作。
- 支持数据快照(snapshot)功能,使得读取操作不受写操作影响,可以在读操作过程中始终看到一致的数据。
- 支持数据压缩(snappy压缩)操作,有效减小存储空间、并增快IO效率。
- LSM典型实现,适合写多读少。
限制
LevelDB 只是一个 C/C++ 编程语言的库,需要封装自己的网络服务器,无法像一般意义的存储服务器(如 MySQL)那样直接用客户端来连接。非关系型数据模型(NoSQL),不支持sql语句,也不支持索引,且一次只允许一个进程访问一个特定的数据库。
编译与使用
源码
源码下载
| |
安装三方模块 直接编译因为 third_party 中缺少 googletest 和 benchmark 子模块,需要单独安装。
| |
安装 sqlite3:
| |
编译
注意避免修改了源码文件导致编译失败。
| |
编译Demo
| |
压测
参照源码中的 benchmarks 目录
整体架构

LevelDB 作为存储系统,数据记录的存储介质包括内存以及磁盘文件。写数据时,接口会同时写入 MemTable(内存)和 Log 文件。当 MemTable 达到阈值时,MemTable 会冻结变成 Immutable MemTable(内存),并将数据写入 SSTable(磁盘上)中,在此同时会生成新的 MemTable 及 Log 文件供新的数据写入。
Log文件
LevelDB 写操作不是直接写入磁盘,而是先写入内存。加入写入到内存的数据还未来得及持久化,发生异常或者服务器宕机等会造成写入的数据丢失。因此,在写入内存之前会首先将所有的写操作写入日志文件中(其它存储系统都是这种通用做法)。每次写操作都是通过 append 方式顺序写入,整体写入性能好效率高。
Memtable
写入操作并不是直接将数据写入到磁盘文件,而是采用先将数据写入内存的方式。memtable 就是使用跳表实现的内存数据结构。数据按用户定义的方法排序之后按序存储,等到其存储内容到达阈值时(4MB)时,便将其转换成一个不可修改的 memtable,与此同时创建一个新的memtable 来供用户进行读写操作。因为使用跳表,它的大多数操作都是O(logn)。
Immutable Memtable
达到 Memtable 设置的容量上限后,Memtable 会变为 Immutable 为之后向SST文件的归并做准备。 同 Memtable 的结构定义一样。两者的区别只是 Immutable Memtable 是只读的。Immutable Memtable 被创建时,LevelDB 的后台压缩进程便会利用其中的内容创建一个sstable,然后持久化到磁盘中。Immutable Mumtable不再接受用户写入,同时生成新的 Memtable、Log 文件供新数据写入。
SSTable文件
磁盘数据存储文件。SSTable(Sorted String Table) 就是由内存中的数据不断导出并进行Compaction 操作后形成的,而且 SSTable 的所有文件是一种层级结构,第一层为Level 0,第二层为 Level 1,依次类推,层级逐渐增高,这也是为何称之为 LevelDB 的原因。此外,Compact 动作会将多个 SSTable 合并成少量的几个 SSTable,以剔除无效数据,保证数据访问效率并降低磁盘占用。
Manifest文件
Manifest 文件中记录SST文件在不同Level的分布,单个SST文件的最大最小key,以及其他一些LevelDB需要的元信息。
Current文件
主要是记录当前 Manifest 的文件名。LevelDB 启动时的首要任务就是找到当前的 Manifest,而 Manifest 文件可能有多个。Current 文件记录了当前 Manifest 的文件名,从而让 LevelDB 启动时能够找到当前的 Manifest。
Reference
9.8.2 - LevelDB-02基础数据结构
Slice
作用
Slice 是 leveldb 中自定义的字符串处理类,主要是因为标准库中的 string 存在如下问题:
默认语义为拷贝,会损失性能。(在可预期的条件下,可以通过指针传递)
使用不太方便,不支持 remove_prefix 和 starts_with 等函数 Slice 的作用:
数据结构简单,包括length和一个指向外部字节数组的指针。
相比返回 std::string,返回 Slice的开销小很多(没有拷贝,Slice 没有实际数据只有指向数据的指针)
允许key和value包含’\0’
兼容性
可以方便实现与 std::string 的互相转换
| |
源码
在 include/leveldb/slice.h 中,较简单。
Key
Key 的代码都在 dbformat.cc / dbformat.h
Key 的关系图如下:

InternalKey
作用
用户输入的数据 key 使用 slice ,LevelDB 则使用 InternalKey 作为 内部key,常用来比较 key 等场景。
结构
[Slice user_key] + [SequenceNumber<<8 + ValueType],后半部分固定64位,即8字节。
LookupKey
作用
查找对象的时候,查找顺序是从第0层到第n层遍历查找,找到为止(最新的修改或者删除的数据会优先被找到)。由于不同层的键值不同,所以 LookupKey 提供了不同层所需的键值。(用于 DBImpl:Get)
结构
| |
memtable_key = start_ + kstart_ + end_
internal_key = kstart_ + end_
user_key = kstart_
ParsedInternalKey
作用
对 InternalKey 的解析,InternalKey 是一个字符串
结构
| |
SkipList
定义
跳跃表:可以代替平衡树的数据结构,可以看成并联的有序链表。跳跃表通过概率保证平衡,而平衡树通过严格的旋转来保证平衡,因此跳跃表实现比较容易,相比平衡树有较高的运行效率。
Redis 中默认的最大 level 为 64。
实现
Expand/Collapse Code Block
| |
Compare
作用
LevelDB 抽象了一个基类 Comparator 用于各种 key 之间的比较,毕竟数据是按照键有序存储的。且必须要支持线程安全。
源码
在 include/leveldb/comparator.h 中
结构
| |
具体实现有:
- BytewiseComparatorImpl
- InternalKeyComparator
BytewiseComparatorImpl
作用
- Slice并没有规定Key具体类型,LevelDB 支持用户自定义比较器,创建数据库对象时可以通过Option 指定。
- 默认的比较器,基于二进制比较
源码
在 util/comparator.cc 中。
InternalKeyComparator
作用
- 用于内部的Key比较器。
- 内部调用的也是 BytewiseComparatorImpl
- cmp原则:
- userkey
- seq 越大越新
- FindShortestSeparator / FindShortSuccessor
- 提取 userkey,通过 userkey 查找
- 追加 kMaxSequenceNumber + kValueTypeForSeek
源码
在 db/dbformat.h 中。
Env
LevelDB 是跨平台的,因此 Env 封装了跨平台的内容。
Env 是一个纯虚类,有三个实现版本:
- PosixEnv:封装了 posix 标准下所有接口
- WindowsEnv:封装了 win 相关接口
- EnvWrapper:对 Env 的扩展,将所有调用转发到其他的 Env 实现
EnvWrapper
EnvWrapper 可以理解成对 Env 的扩展,使用了代理模式来实现扩展。
9.8.3 - LevelDB-03Log
Log作用
对于DB最怕的就是数据的丢失。当服务挂掉时,应尽可能的减少数据丢失。在 leveldb 中引入了 WAL 日志。
基本组成
每个 Log 被划分成了很多 32K 大小的物理 block,写入、读取操作都是以 block 为单位进行。
| |
写入流程
入口:
| |
AddRecord
Expand/Collapse Code Block
| |
EmitPhysicalRecord
接着查看 EmitPhysicalRecord 函数
Expand/Collapse Code Block
| |
Sync
注意 DBImpl::Write 函数中调用完 AddRecord 后立马调用了 Sync 函数进行了同步。
删除日志
doc(doc/impl.md)文档里面讲解了,在打开数据库以及compact之后,会将不再使用的文件删除,使用的函数是 RemoveObsoleteFiles。可以通过添加日志或者 gdb 来查看。
打开数据库
| |
数据压缩
| |
9.8.4 - LevelDB-04数据读写
数据写入
基本原理
新增记录
一个插入操作 Put(key, value) 包含两个具体步骤:
- 追加写入 log
以顺序写的方式追加到 log 文件末尾。磁盘顺序写的方式效率很高,不会导致写入速度的急剧降低。
- 写入 memtable
如果写入 log 文件成功,那么记录也会插入内存的 Memtable 中,Memtable 是一个 key 有序的跳表。
正是因为一个插入操作涉及一次磁盘文件追加写和内存跳表的插入操作,所以 LevelDB 写入速度很高效。
删除记录
删除一条记录并不是立即执行删除操作,而是与插入操作相同,只不过插入操作是插入 key:value 值,而删除操作是插入 key:删除标记,等后台 Compaction 时才执行真正的删除操作。
WriteBatch
WriteBatch 使用批量写来提高性能,支持 put 和 delete。
结构
Expand/Collapse Code Block
| |
写入数据
代码:include/leveldb/write_batch.h;db/write_batch_internal.h
| |
调用 db->Put(WriteOptions(),&key,&value); 写入数据。WriteOptions 只有一个变量 sync,默认初始值为 false,因此默认写数据方式是异步。即每次写操作只要将数据写入到内存中就返回,而将数据从内存写到磁盘的方式是异步的。 异步写的效率比同步写高很多,问题是系统故障时可能会导致最近的写入丢失。
| |
LevelDB 使用 WriteBatch 替代简单的异步写操作。首先将所有的写操作记录到一个 batch 中,然后执行同步写,这样同步写的开销就被分散到多个写操作中。
写操作
写接口
Expand/Collapse Code Block
| |
写实现
代码:db/db_impl.h
| |
注意调用流程:leveldb::DBImpl::Put => leveldb::DB::Put => leveldb::DBImpl::Write
DBImpl::Write
代码:db/db_impl.cc
基本流程:
- 构造 Writer
- 将 writebatch 存入到一个 Writer 中,
- 将 Writer 存入 deque 中。(levedb支持多线程,需要加互斥锁保护writers_)
- 每个生产者在向 writers_ 队列中添加任务之后,都会进入一个 while 循环在里面等待。只有当该生产者加入的任务已经被处理或位于队列的头部,线程才会被唤醒。注意线程被唤醒后会继续检查循环条件,满足条件会继续睡眠。
- 加入的任务被其他任务处理,线程直接退出。
- 加入的任务排在了队列的头部且未处理,当前线程将消费者进行后续处理。
Expand/Collapse Code Block
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47// Writer 结构 struct DBImpl::Writer { explicit Writer(port::Mutex* mu) : batch(nullptr), sync(false), done(false), cv(mu) {} Status status; WriteBatch* batch; bool sync; bool done; port::CondVar cv; }; Status DBImpl::Write(const WriteOptions& options, WriteBatch* updates) { // 构造 Writer Writer w(&mutex_); w.batch = updates; w.sync = options.sync; w.done = false; MutexLock l(&mutex_); // 将 Writer push 到 deque 中 writers_.push_back(&w); // 构造 Writer 未执行完时(如合并操作,可能会被其它线程执行完成), // 且未到队列头(没有获得调度)时,则等待 while (!w.done && &w != writers_.front()) { w.cv.Wait(); } // 如果Writer任务被其它writer执行完成,则返回。 if (w.done) { return w.status; } // 真正执行调度 ... // 将处理完的任务从队列中取出,设置状态为 true,然后通知对应的 port::CondVar while (true) { Writer* ready = writers_.front(); writers_.pop_front(); if (ready != &w) { ready->status = status; ready->done = true; ready->cv.Signal(); } if (ready == last_writer) break; } // 通知队列中的首 Writer if (!writers_.empty()) { writers_.front()->cv.Signal(); } return status; }
数据读取
数据读取流程
- Memtable 查找:首先会去查看内存中的 Memtable,如果 Memtable 中包含key及其对应的value,则直接返回;
- Immutable Memtable 查找:接下来会到内存中的 Immutable Memtable 中查找,读到则返回;
- SSTable 查找:SSTable数量较多且分成多个 level。首先从属于 level 0 的文件中查找,如果找到则直接返回,如果没有找到则到下一个 level 的文件中查找,如此循环往复直到找到或查遍所有 level 没有仍然找到返回不存在为止。
SST
数据分布
- level 0下的不同文件可能key的范围有重叠,某个要查询的key有可能多个文件都包含。
策略是先找出 level 0 中哪些文件包含这个key(manifest文件中记载了level和对应的文件及文件里key的范围信息,内存中保存该映射表),之后按照文件的新鲜程度排序,新的文件排在前面,之后依次查找,读出key对应的value。
- 非level 0下的不同文件之间key是不重叠的,所以只从一个文件就可以找到key对应的value。
查询过程
如果命中了 SST,那么查询过程如下:
- 一般先在内存中的 Cache 中查找是否包含这个文件的缓存记录,找到则从缓存中读取;
- 然后打开 SSTable 文件,同时将文件的索引部分加载到内存中存入 Cache(只有索引部分在 Cache中);
- 根据索引定位到哪个 Block 包含 key,从文件中读出 Block 的内容,然后根据记录逐一比较,找到则返回,没有找到则到下一级别的 SSTable 中查找。
读操作
读接口
代码:include/leveldb/db.h
| |
读实现
代码:db/db_impl.h
| |
DBImpl::Get
Expand/Collapse Code Block
| |
注意:MemTable、Immutable Memtable 和 Current Version 查找不需要加锁,因为前两个是 SkipList,其读操作是线程安全的,只需要通过引用计数保证数据结构不被回收即可。Current Version 内部是 SSTable 文件,都是只读操作,也无需加锁。
9.8.5 - LevelDB-05Cache
简介
为了读取效率使用了 Cache 机制。主要是 Table Cache 和 Block Cache 两类。
Table Cache 主要是缓存 SST 文件的 data block index,Block Cache 主要是缓存 data block。
通用接口
代码:include/leveldb/cache.h
Expand/Collapse Code Block
| |
LRUHandle
代码:util/cache.cc
LRUHandle 类主要用于自定义的 hashtable 和 LRU 中的节点。
| |
LRUCache
数据结构
Expand/Collapse Code Block
| |
注意事项:
- LRU 中元素不仅在 cache 中,也可能会被外部引用,不能直接删除节点
- 某个节点被修改或引用,空间不足不能参与 LRU 计算
- in_use 表示既在 cache 中,也被外部引用
- table_ 记录 key 和节点的映射关系,通过key可以快速定位到某个节点
- 调用 insert/LookUp 之后,必须使用 Release 释放句柄
Insert函数
代码:util/cache.cc
Expand/Collapse Code Block
| |
ref/Unref函数
代码:util/cache.cc
Ref
| |
Unref
| |
9.8.6 - LevelDB-06Compaction
分类
LevelDB 中,compaction 有两种:
- minor compaction
immutable memtable 持久化为 sst 文件
- major compaction
sst 文件之间的 compaction
* Manual Compaction:人工触发,外部接口调用产生
* Size Compaction:每个 level 文件大小超过一定阈值就会触发
* Seek Compaction:一个文件的 seek miss 次数超过阈值就会触发
优先级:Minor > Manual > Size > Seek
成员变量
代码:
db/version_set.h
Expand/Collapse Code Block
| |
主要函数
Expand/Collapse Code Block
| |
Minor Compaction
定义
immutable memtable 持久化为 sst 文件。
触发条件
Wirte 新数据进入 LevelDB 时,会在适当的时机检查内存中 Memtable 占用内存大小,一旦超过 options_.write_buffer_size (默认4M),就会尝试 Minor Compaction。
执行过程
- DBImpl::BackgroundCompaction -> DBImpl::CompactMemTable -> DBImpl::WriteLevel0Table
- BuildTable:将 immutable memtable 格式化成 sstable 文件。
- PickLevelForMemTableOutput:计算新生成的sstable所属的层级。
- edit->AddFile():将新sst文件放置到第2步选出的level中。 策略上尽量将新 compact 文件推至高 level。因为如果 level0 需要控制的文件过多,compaction IO 和查找都比较耗费。另一方面也不能推至过高level,某些范围的key更新比较频繁,后续往高层 compaction IO 消耗也很大。
层级选择
新 sst key 范围和 level0 的某个或某几个 sst 文件是否有重叠
- 是,level = 0
否,新 sst key 范围和 level1 的某个或某几个 sst 文件是否有重叠
- 是,level = 0
否,level2 文件中与新 sst 有重叠文件个数过多,size之和是否超过阈值
- 是,level = 0
否,新 sst key 范围和 level2 的某个或某几个 sst 文件是否有重叠
- 是,level = 1
否,level3 文件与新 sst 重叠文件个数过多,size之和是否超过阈值
- 是,level = 1
否,level = 2 基本判断原则:
当前level n,推向下一层level的条件是:与 level n+1 不能重叠,与 level n+2 重叠的文件大小不能超过阈值
level 最大不超过2
Major Compaction
Major compaction 是将不同层级的 sst 的文件进行合并。
作用:
- 将不活跃的数据下沉,均衡各个level的数据,保证 read 的性能
- 合并 delete 数据,释放磁盘空间,因为删除是标记删除
- 合并 update 数据,例如put同一个key,类似于 delete,是采用的标记插入新的数据,实际的update是在compact中完成,并实现空间的释放
Size Compaction
定义
LevelDB 的核心 Compact 过程,其主要是为了均衡各个level的数据,从而保证读写的性能均衡。
主要是指某一层 sst 文件不能太大,这个大对 level0 层来说是 sst 文件过多,因为 level0 层会被频繁访问,而对于其他层表示字节数太大,具体见Builder类的Finalize函数。
触发条件
LevelDB 会计算每个level的总的文件大小,并根据此计算出一个score,最后会根据这个score来选择合适level和文件进行Compact。具体得分原则见:
| |
进行 Compation 时,判断得分是否大于 1,是则进行 Size Compaction。代码见:
| |
执行过程
- score计算:各 level 触发得分,得到 compaction 层级(VersionSet::Finalize)
- level0: level0文件总数 / 4
- 其它 level:当前level所有文件size之和 / 当前 level 阈值
- 寻找 compaction 的文件,如 level n:
- 确定 level n 参与 compation 的文件列表 ,存入inputs_[0] (核心函数:VersionSet::PickCompaction)
- 确定 level n+1 参与 compation 的文件列表,存入inputs_[1](核心函数:VersionSet::SetupOtherInputs)
Seek Compaction
定义
主要记录的是某个 sst seek 次数到达阈值之后,将会参与下一次压缩。
LevelDB 认为如果一个 sst 文件在 level i 中总是没总到,而是在 level i+1 中找到,这说明两层之间key的范围重叠很严重。当这种 seek miss 积累到一定次数之后,就考虑将其从 level i 中合并到 level i+1 中,这样可以避免不必要的 seek miss 消耗 read I/O。
触发条件
当 allowed_seeks 递减到小于0了,将标记为需要 compation 的文件。但是由于 Size Compaction 优先级高于 Seek Compaction,所以在不存在 Size Compaction 时且触发了Compaction,Seek Compaction 就能执行。
执行过程
- 获取 compaction 文件(Version::UpdateStats)
- 寻找 compaction 的文件,如 level n:
- 确定 level n 参与 compation 的文件列表 ,存入inputs_[0] (核心函数:VersionSet::PickCompaction)
- 确定 level n+1 参与 compation 的文件列表,存入inputs_[1](核心函数:VersionSet::SetupOtherInputs) 具体代码见:DBImpl::DoCompactionWork
Manual Compact
定义
人工触发的Compaction,由外部接口调用产生。实际内部触发调用的接口是 DBImpl 中的
| |
Manual Compaction 中会指定 begin 和 end。它将会逐个 level 分次的 Compact 所有level 中与 begin 和 end 有重叠(overlap)的 sst 文件。
触发条件
人工触发,由外部调用。
执行过程
- 遍历所有level,获取到最大重叠的层级(核心函数:OverlapInLevel)
- 强制将当前的 memtable 进行 minor compation。(核心函数:TEST_CompactMemTable)
- 遍历重叠的层级进行 major compation(核心函数:TEST_CompactRange)
- 真正的 compation(核心函数:VersionSet::CompactRange)
9.9 - ClickHouse
Introduction
ClickHouse介绍
9.9.1 - 01.ClickHouse基本介绍
简介
ClickHouse是一个快速、可扩展的用于联机分析(OLAP)的列式数据库管理系统(DBMS),专门用于在线分析处理(OLAP)工作负载。它是一个开源项目,由Yandex团队开发和维护。ClickHouse的设计目标是提供高性能的数据分析能力,是一个高性能、可扩展、实时数据分析的列式数据库系统,非常适合需要处理大量数据和高并发查询的场景。
特点
以下是ClickHouse的一些主要特点:
- 列式存储:ClickHouse采用列式存储结构,这意味着它将相同类型的数据存储在一起,以便更高效地压缩和查询。这使得它在需要扫描和聚合大量数据时非常快。
- 可扩展性:ClickHouse可以水平扩展,允许在多个节点上分布数据和负载。它可以处理PB级别的数据,并能够自动管理数据分片、复制和故障转移。
- 高性能:ClickHouse的查询速度非常快,尤其是在需要执行聚合操作时。它支持高并发查询和低延迟查询,并可以处理几百万行数据每秒的查询吞吐量。
- 支持SQL:ClickHouse支持SQL语言,包括各种聚合函数、子查询、JOIN操作和分组。它还支持分布式SQL查询。
- 实时数据分析:ClickHouse可以实时处理和分析数据,支持近实时查询和数据导入。它还支持流数据处理,可以与Kafka、Spark Streaming等实时数据处理系统集成。
应用场景
ClickHouse适用于需要处理大型数据集和高并发查询的场景,例如电商网站的用户行为分析、金融交易数据分析、游戏数据分析等。
核心特性
MPP (Massively Parallel Processing),即大规模并行处理,将任务并行的分散到多个服务器和节点上,在每个节点上计算完成后,将各自部分的结果汇总在一起得到最终的结果(与hadoop相似)。
- 多个节点通过网络进行连接协同工作,完成相同的任务(分布式计算+分布式存储)
- 每个节点只访问自己的本地资源(内存、存储等)
- 完全无共享(Share Nothing)结构,因而扩展能力非常好 ClickHouse是一款MPP架构的列式存储数据库,吸取了其他优秀技术的精髓,将每个细节做到极致,从而在性能上远远超过其他技术。
DBMS功能
ClickHouse拥有DBMS完备的管理功能,但它不仅仅是一个数据库。作为DBMS,它具备以下基本功能。
- DDL(数据定义语言):可以动态地创建、修改删除数据库、表和视图,而无须重启服务。
- DML(数据操作语言):可以动态查询、插入、修改或者删除数据。
- 权限控制:可以按照用户粒度设置数据库或者表的操作权限,保障数据的安全性。
- 数据备份与恢复:提供了数据备份导出与导入恢复机制,满足生产环境的要求。
- 分布式管理:提供集群模式,能够自动管理多个数据库节点。
支持SQL
ClickHouse使用关系型模型描述数据,并提供了传统数据库的概念,如数据库、表、视图和函数等。ClickHouse可以使用SQL作为查询语言。
- Hive
- Impala是Cloudera公司主导开发的新型查询系统,它提供SQL语义,能查询存储在Hadoop的HDFS和HBase中的PB级大数据。
- Spark SQL
- Druid 是一个分布式的数据分析平台,预聚合算是 Druid 的一个非常大的亮点,通过预聚合可以减少数据的存储以及避免查询时很多不必要的计算。
- Apache Kylin™是一个开源的、分布式的分析型数据仓库,提供Hadoop/Spark 之上的 SQL 查询接口及多维分析(OLAP)能力以支持超大规模数据,最初由 eBay 开发并贡献至开源社区。它能在亚秒内查询巨大的表。
表引擎
与MySQL类似ClickHouse将存储部分进行了抽象,把存储引擎作为一层独立的接口。ClickHouse目前拥有合并树、内存、文件、接口和其他6大类等20多种表引擎,用户可以根据实际应用场景,选择合适的表引擎使用。
通用的表引擎可以有更广泛的适用性,能适应更多的应用场景,但这种通用性可能造成它无法在所有应用场景内性能做到极致,也是一种平庸的表现。
将表引擎独立设计,可以通过特定的表引擎支撑特定的场景,用法十分灵活。对于简单的应用场景,可以直接使用简单的引擎降低成本,而复杂的应用场景也有合适的表引擎。
列式存储
行式存储和列式存储,数据在磁盘上的组织结构有着根本不同,数据分析计算时,行式存储需要遍历整表,列式存储只需要遍历单个列,所以列式库更适合做大宽表,用来做数据分析计算。
列式存储和数据压缩,是高性能数据库必不可少的重要特性。如果想让查询变得更快,最简单且最有效的方法就是减少数据扫描范围和数据传输时的大小。
向量化执行
向量化执行,就是利用寄存器硬件层面的特性,为上层应用程序的性能带来了指数级的提升。
为了实现向量化执行,需要利用CPU的SIMD(Single Instruction Multiple Data,即用单条指令操作多条数据)命令,通过数据并行来提高性能。即在CPU寄存器层面实现数据的并行操作。
并行计算
向量化执行是通过数据级并行的方式提升了性能,多线程处理是通过线程级并行的方式实现了性能的提升。
ClickHouse在数据存取方面,既支持分区(纵向扩展,利用多线程技术),也支持分片(横向扩展,利用分布式技术)。ClickHouse将多线程和分布式的技术应用到了极致。
实时查询
ClickHouse支持实时查询,即便是在复杂查询的场景下,也能够做到极快响应,且无需对数据进行任何预处理加工。
与其他类似产品对比优势:
- SparkSQL和Hive无法保障90%的查询在1秒内容返回,在海量数据的复杂查询可能需要分钟级别的响应时间
- ElasticSearch在处理亿级数据聚合查询的时候,会显得力不从心
多主架构
ClickHouse则采用Multi-Master多主架构,集群中每个节点角色对等,客户端访问任意一个节点都能得到相同的效果。 规避了单点故障的问题,非常适合用于多数据中心、异地多活的场景。(对比Hadoop生态系统技术都采用了Master-Slave主从架构)
数据分片
ClickHouse支持分片,采用分治思想,将数据进行横向切分,解决存储和查询的瓶颈。ClickHouse分片依赖集群,每个集群由1个到多个分片组成,而每个分片则对应ClickHouse的1台服务器节点,分片的数量取决于节点数量(一个分片只能对应一台服务器节点)。
ClickHouse的分片功能没有那么自动化,它提供了一个本地表(Local Table)和分布式表(Distribute Table)的概念。
- 一张本地表等同于一份数据分片。
- 分布式表本身不存储任何数据,它是本地表的访问代理,作用类似分库中间件。借助分布式表,能够代理访问多个数据分片,从而实现分布式查询。 这种设计非常灵活,业务上线初期数据量不大,采用单节点的本地表即可,随着数据量增大, 新增分片的方式分流数据,通过分布式表实现分布式查询。
架构
ClickHouse的架构包括多个节点,每个节点可以担任多个角色,例如数据存储、数据计算、查询处理等。ClickHouse支持多种数据分片和副本策略,可以在多个节点上分布数据和负载。

Column和Field
Column和Field是ClickHouse数据最基础的映射单元。内存中的一列数据由一个Column对象表示。如果需要操作单个具体的数值(也就是单列中的一行数据),则需要使用Field对象,Field对象代表一个单值。IColumn接口对象中,定义了对数据进行各种关系运算的方法。
DataType
数据的序列化和反序列化工作由DataType负责。但并不直接负责数据的读取,而是转由从Column或Field对象获取。IDataType接口定义了许多正反序列化的方法,成对出现。
Block与Block流
ClickHouse内部的数据操作是面向Block对象进行的,并且采用了流的形式。Block对象可以看作数据表的子集,包含了数据的类型及列的名称。
Block并没有直接聚合Column和DataType对象,而是通过ColumnWithTypeAndName对象进行间接引用。
Block流操作有两组顶层接口:IBlockInputStream负责数据的读取和关系运算,IBlockOutputStream负责将数据输出到下一环节。
Table
数据表的底层设计中并没有所谓的Table对象,直接使用IStorage接口指代数据表。
Parser和Interpreter
Parser分析器负责创建AST对象;Interpreter解释器则负责解释AST,并进一步创建查询的执行管道。它们与IStorage一起,串联起了整个数据查询的过程。
Function
ClickHouse主要提供两类函数——普通函数和聚合函数。普通函数由IFunction接口定义。
聚合函数由IAggregateFunction接口定义,相比无状态的普通函数,聚合函数是有状态的。
Cluster与Replication
ClickHouse的集群由分片(Shard)组成,而每个分片又通过副本(Replica)组成。
ClickHouse的分片与其他系统有所区别:
- ClickHouse的1个节点只能拥有1个分片
- 分片只是一个逻辑概念,其物理承载还是由副本承担的
数据模型
ClickHouse支持多种数据类型和表引擎,可以存储结构化和非结构化数据。它支持多种聚合函数、子查询、JOIN操作和分组,还支持分布式SQL查询。
逻辑数据模型
一个数据库有若干个分布式表组成,每张表会有多个分片,每个分片会有多个副本。

物理数据模型

数据分区:每个分片副本的内部,数据按照 PARTITION BY 列进行分区,分区以目录的方式管理,本文样例中表按照时间进行分区。
列式存储:每个数据分区内部,采用列式存储,每个列涉及两个文件,分别是存储数据的 .bin 文件和存储偏移等索引信息的 .mrk2 文件。
数据排序:每个数据分区内部,所有列的数据是按照 ORDER BY 列进行排序的。可以理解为:对于生成这个分区的原始记录行,先按 ORDER BY 列进行排序,然后再按列拆分存储。
数据分块:每个列的数据文件中,实际是分块存储的,方便数据压缩及查询裁剪,每个块中的记录数不超过 index_granularity,默认 8192。
主键索引:主键默认与 ORDER BY 列一致,或为 ORDER BY 列的前缀。由于整个分区内部是有序的,且切割为数据块存储,ClickHouse 抽取每个数据块第一行的主键,生成一份稀疏的排序索引,可在查询时结合过滤条件快速裁剪数据块。
索引
索引结构是用于加速数据查询的关键组件之一。ClickHouse的索引结构通常是用于辅助查询的,而不是强制性的。ClickHouse支持全表扫描,因此即使没有索引也可以快速查询大量数据。但是,对于大型数据集和复杂的查询,索引结构可以显著提高查询性能并减少查询时间。
索引结构
ClickHouse支持多种不同的索引结构,包括以下几种:
哈希索引
哈希索引(Hash Index)将数据列中的每个唯一值映射到一个桶中,从而加快查找特定值的速度。哈希索引适用于等值查询,但不适用于范围查询或排序操作。
B+树索引
B+树索引(B+Tree Index)是一种常见的树状结构,可以支持范围查询和排序操作。在ClickHouse中,B+树索引被广泛用于处理时间序列数据等常见场景。
倒排索引
倒排索引(Inverted Index)将每个值映射到包含该值的行号列表中。倒排索引适用于文本搜索等场景,但在处理大量数据时可能会消耗大量内存。
Bloom过滤器
Bloom过滤器(Bloom Filter)是一种用于快速确定元素是否在集合中的概率型数据结构。Bloom过滤器可以用于加速查询过滤,但可能会导致一定的误判率。
空间索引
空间索引(Spatial Index)是一种用于处理空间数据(例如地理坐标)的索引结构。ClickHouse支持多种空间索引结构,包括KD树、R树和Hilbert曲线等。
索引查询流程
在 ClickHouse 中,索引结构是用于加速数据查询的关键组件。当执行查询时,ClickHouse会使用索引结构来定位数据并返回查询结果。下面是 ClickHouse 索引如何找到对应的数据的简要过程:
- 首先,ClickHouse会根据查询条件选择合适的索引结构,例如哈希索引、B+树索引等等。
- 接着,ClickHouse会使用查询条件中的值在索引结构中进行查找。例如,如果是哈希索引,ClickHouse会将查询条件中的值通过哈希函数计算得到一个桶号,然后在对应的桶中查找是否存在对应的值。
- 如果在索引结构中找到了匹配的数据,ClickHouse会返回对应的行号(在数据文件中的位置),然后通过行号在数据文件中读取对应的数据。
- 如果索引结构无法找到匹配的数据,ClickHouse会返回空结果。 在某些情况下,ClickHouse可能会选择不使用索引结构而是直接进行全表扫描来查找数据。这通常发生在索引结构不适用于查询条件的情况下(例如,如果查询条件使用了某个列的范围查询,而哈希索引只适用于等值查询)或者数据集较小时。
工作流程
查询流程
查询的处理过程可以分为以下几个步骤:
- 语法解析和查询构建:当用户发出一个查询请求时,ClickHouse首先会解析查询语句并构建一个查询执行计划。这个计划将指定查询的所有步骤,包括要使用的表、列、索引和函数,以及查询如何被处理和优化。
- 查询优化:在查询执行计划构建完成后,ClickHouse会对它进行一系列优化操作,以使查询尽可能高效。这些优化操作包括推迟计算、重写查询、选择索引和预取等。
- 查询执行:一旦查询执行计划和优化都完成,ClickHouse将开始执行查询。查询执行过程包括在所有分片和节点上扫描数据、聚合和过滤数据、使用索引来优化查询等。
- 数据合并和排序:当查询执行完成后,ClickHouse会将分布式的查询结果合并成一个单一的结果集。如果查询包括排序操作,ClickHouse还将进行排序操作以获得最终的结果集。
- 结果返回:最后,ClickHouse将查询结果返回给客户端,客户端将获得一个包含查询结果的响应。 需要注意的是,ClickHouse是一个分布式的数据库系统,因此上述查询流程将在多个节点和分片上并行执行。在查询执行过程中,ClickHouse会自动将查询分发到多个节点上,并对结果进行合并和排序。这使得ClickHouse能够高效地处理大量的数据,并在秒级甚至亚秒级别提供响应。
写入流程
ClickHouse 数据写入的基本流程:
- 客户端向 ClickHouse 发送一个 INSERT 查询,包含要写入的数据。
- ClickHouse 的负责接收数据的进程(通常称为“数据节点”)接收到这个查询,并检查表结构和数据类型是否匹配。
- 如果数据类型不匹配,ClickHouse 会将数据转换为正确的类型,或者拒绝写入数据。
- 数据节点将接收到的数据暂时存储在一个名为“内存表”的数据结构中。内存表是一个类似于缓存的数据结构,可以快速地接收、处理和存储数据。
- 当内存表的数据量达到一个预定义的阈值(通常是几百万或几千万行),ClickHouse 将内存表中的数据写入到磁盘上的数据文件中。这个过程被称为“刷盘(flushing)”。
- 写入数据的过程是异步的,这意味着数据可以在后台写入,而不会阻塞正在进行的查询。当写入完成后,ClickHouse 将会通知客户端写入成功。
- 一旦数据被写入到数据文件中,它就可以被查询和聚合。为了提高查询性能,ClickHouse 将数据文件划分成多个小的数据块,每个数据块称为一个“分区(partition)”。每个分区包含一个或多个桶(bucket),可以根据需要进行读取和处理。 ClickHouse 的数据写入流程是一个非常高效和灵活的过程,可以快速地接收、处理和存储大量的数据。通过将数据存储在内存表中,并使用异步写入和分区等技术,ClickHouse 可以在处理大规模数据时实现卓越的性能和效率。
更新流程
ClickHouse 更新数据的流程与传统的关系型数据库有所不同。由于 ClickHouse 的设计目标是高效地处理大规模数据,因此并不支持像传统数据库中那样的“原地更新”(in-place update)操作。更新操作被视为删除旧数据并插入新数据的组合操作。以下是 ClickHouse 中更新数据的基本流程:
- 客户端向 ClickHouse 发送一个 UPDATE 查询,包含要更新的数据。
- ClickHouse 的数据节点接收到这个查询,并检查表结构和数据类型是否匹配。
- 如果数据类型不匹配,ClickHouse 会将数据转换为正确的类型,或者拒绝更新数据。
- ClickHouse 执行一个 DELETE 查询来删除符合更新条件的旧数据。删除操作的效率非常高,因为 ClickHouse 可以将需要删除的数据的位置记录在特殊的“删除列表”(delete list)中,而不是实际删除数据。
- ClickHouse 接着执行一个 INSERT 查询,将更新后的数据插入到表中。插入操作的效率也非常高,因为 ClickHouse 可以将插入的数据暂时存储在内存表中,并在内存表中有足够的数据时一次性写入到磁盘上的数据文件中。
- 更新操作的效率受多个因素影响,包括更新条件的复杂度、数据文件大小、磁盘读写速度等等。 ClickHouse 中的数据更新操作是一种删除旧数据并插入新数据的组合操作。这种设计可以帮助 ClickHouse 在高效处理大规模数据时保持高性能和效率。由于更新操作的效率受多个因素影响,因此在实际使用中需要仔细考虑更新数据的策略。
性能优化
ClickHouse使用多种性能优化技术来提高查询效率和扩展性,例如使用多种压缩算法和编码技术来减少存储空间,使用数据预取和批量读取技术来提高查询效率,使用多种查询优化技术来加速查询等。
其性能优化主要包括以下几个方面:
- 列式存储:ClickHouse 采用列式存储,将同一列的数据存储在一起,避免了传统行式存储的随机读写,大大提高了数据的读取效率。此外,列式存储还可以压缩相同列中重复的数据,从而减少了磁盘存储和传输数据的大小。
- 多级索引:ClickHouse 采用多级索引,可以在不完全扫描所有数据的情况下进行快速的过滤和聚合操作,从而提高了查询效率。
- 数据分区:ClickHouse 可以将数据分为多个分区,每个分区包含一组连续的数据,可以加快查询和聚合操作的速度。
- 向量化计算:ClickHouse 在进行聚合计算时使用向量化计算,将多个计算任务组合成一个单一的指令,减少了CPU指令执行的次数,从而提高了计算效率。
- 异步写入:ClickHouse 将数据写入到磁盘上的数据文件时采用异步写入的方式,可以将写入操作放入后台执行,避免了写入操作对查询性能的影响。
- 数据压缩:ClickHouse 支持多种数据压缩算法,可以将数据文件的大小压缩到原来的几分之一或几十分之一,从而减少了磁盘存储和传输数据的大小。 这些技术的应用使得 ClickHouse 可以高效地处理大规模的数据,并在查询和聚合等操作中保持卓越的性能和效率。
文件组织
大部分的DBMS中,数据库本质上就是一个由各种子目录和文件组成的文件目录,clickhouse也不例外。下图展示了clickhouse对数据文件的组织。

数据库目录
clickhouse默认数据目录在 /var/lib/clickhouse/data 目录中。所有的数据库都会在该目录中创建一个子文件夹。
每一个数据库都会在clickhouse的data目录中创建一个子目录,clickhouse默认携带default和system两个数据库。default顾名思义就是默认数据库,system是存储clickhouse服务器相关信息的数据库,例如连接数、资源占用等。
表目录
每个表都有一个对应的表目录,用于存储该表的所有数据块。表目录的默认路径为 /var/lib/clickhouse/data/<database_name>/<table_name>/
分区目录
ClickHouse支持按照分区进行数据存储和查询,数据表的分区数据存储在该目录下。分区(Partition)是指将表按照某个列或表达式进行划分,将同一分区中的数据存储在相同的数据块中,以便更高效地查询和管理数据。
每个分区目录包含一些文件,其中包括数据文件、索引文件等。分区目录的名称可以是任何字符串,但是建议使用可以表示时间的字符串,这样可以方便地按时间范围查询数据。
数据文件和索引文件
分区目录下就能看到真实存储的数据文件和索引文件:
- columns.txt:存储表结构信息。
- count.txt:存储该分区下的行数。执行select count(*) from table即返回该内容,而不是遍历数据。
- primary.idx:主键索引。
- checksums.txt:二进制文件,校验和。用于快速校验数据是否被篡改。
- default_compression_codec.txt:(新版本增加的文件)存储数据文件中使用的压缩编码器。默认使用LZ4。
- [column].mrk3:列的标记文件。
- [column].bin:真正存储数据的数据文件。每一列都会生成一个单独的bin文件。
- skp_idx_[column].idx:跳数索引,使用二级索引时会生成。
- skp_idx_[column].mrk:条数索引标记文件,使用二级索引时会生成。
数据组织
bin文件是二进制文件,在读取时需要借助工具,无法使用文本文件进行读取。在windows操作系统下建议使用winhex,mac系统推荐hex friend。
数据文件结构

bin文件使用小端字节序存储。bin文件中按block为单位排列数据,每个block文件有16字节校验和,1字节压缩方式,4字节压缩后大小和4字节的压缩前大小组成。每个block起始地址由如下公式确定:
| |
校验和
前16为检验和区域用于快速验证数据是否完整。
压缩方式
默认为0x82。clickhouse共支持4种压缩方式,分别为LZ4(0x82)、ZSTD(0x90)、Multiple(0x91)、Delta(0x92)。
压缩后大小
存储在data区域的数据的大小。需要依据此大小计算下一个BLOCK的偏移量。
压缩前大小
data区域存储的数据在压缩前的大小。可以依据此计算压缩比。
data区
data区存储数据,大小为头信息第18~21字节表示的大小。拿到data区数据后,由于是压缩后的,因此无法直接识别,需要按照压缩方式进行解压缩后,才能识别。
Reference
https://clickhouse.com/docs/zh/
https://clickhouse.com/docs/zh/development/architecture
https://www.modb.pro/db/467020
10 - 存储
Introduction
存储相关
10.1 - Ceph
Introduction
存储相关
10.1.1 - 01.Ceph介绍
简介
Ceph是一个C++开发的开源分布式存储系统,设计初衷是提供较好的性能、可靠性和可扩展性。可以提供块、文件和对象存储,被广泛应用于云计算、大数据等领域。
Ceph 项目最早起源于 Sage 就读博士期间的工作(最早的成果于 2004 年发表),并随后贡献给开源社区。在经过了数年的发展之后,目前已得到众多云计算厂商的支持并被广泛应用。
Ceph的核心组件是RADOS(Reliable Autonomic Distributed Object Store)存储系统,它将数据分成多个对象,并将这些对象分布在不同的物理服务器上存储。Ceph使用CRUSH算法来管理数据和存储节点,CRUSH算法是一种自适应的分布式数据放置算法,能够实现数据的负载均衡和故障恢复。Ceph还提供了一套完整的API接口,支持块存储、对象存储、文件存储等多种数据存储方式,同时还支持多种数据访问协议,如CephFS、RADOS Gateway等。RedHat 及 OpenStack 都可与 Ceph 整合以支持虚拟机镜像的后端存储。
优势
Ceph的优势可以概括为以下几个方面:
- 高性能
- 摒弃了传统的集中式存储元数据寻址的方案,采用CRUSH算法,数据分布均衡,并行度高
- 考虑了容灾域的隔离,能够实现各类负载的副本放置规则,例如跨机房、机架感知等
- 能够支持上千个存储节点的规模。支持TB到PB级的数据
- 高可用
- 副本数可以灵活控制
- 支持故障域分隔,数据强一致性
- 多种故障场景自动进行修复自愈
- 没有单点故障,自动管理
- 高扩展性
- 去中心化
- 扩展灵活
- 随着节点增加,性能线性增长
- 特性丰富
- 支持三种存储接口:对象存储,块设备存储,文件存储
- 支持自定义接口,支持多种语言驱动
- 开源自由
- Ceph是一款完全开源的存储系统,采用LGPLv2.1许可证,用户可以自由使用和修改其源代码。
架构

Ceph的架构可以分为三个层次:RADOS、Ceph OSD和Ceph Client。
RADOS层
RADOS(Reliable Autonomic Distributed Object Store)将数据分成多个对象,并将这些对象分布在不同的物理服务器上存储。RADOS使用CRUSH算法来管理数据和存储节点,CRUSH算法是一种自适应的分布式数据放置算法,能够实现数据的负载均衡和故障恢复。RADOS还提供了多种数据访问协议,如RADOS Block Device(RBD)、RADOS Gateway等。
Ceph OSD层
Ceph OSD(Object Storage Device)是一个运行在每个存储节点上的进程,负责管理RADOS中的对象存储和数据副本的复制。每个Ceph OSD都可以处理读取和写入请求,并保持与其他OSD节点之间的数据同步。此外,Ceph OSD还提供了一套完整的API接口,支持块存储、对象存储、文件存储等多种数据存储方式。
Ceph Client层
Ceph Client是Ceph的用户访问层,它提供了多种数据访问协议,如CephFS、RADOS Gateway等,以满足不同的数据访问需求。Ceph Client还负责将数据请求分发给Ceph OSD节点进行处理,并将结果返回给用户。
总体来说,Ceph的架构设计具有高度的可扩展性和可靠性,能够满足大规模分布式存储系统的需求。
基本概念
Object
Ceph 最底层的存储单元是 Object 对象,每个 Object 包含元数据和原始数据。
PG
PG 全称 Placement Grouops,是一个逻辑的概念,一个 PG 包含多个 OSD。引入 PG 这一层其实是为了更好的分配数据和定位数据。
RADOS
RADOS 全称 Reliable Autonomic Distributed Object Store,是 Ceph 集群的精华,用户实现数据分配、Failover 等集群操作。
Libradio
Librados 是 Rados 提供库,因为 RADOS 是协议很难直接访问,因此上层的 RBD、RGW 和 CephFS 都是通过 librados 访问的,目前提供 PHP、Ruby、Java、Python、C 和 C++支持。
CRUSH
CRUSH 是 Ceph 使用的数据分布算法,类似一致性哈希,让数据分配到预期的地方。
RBD
RBD 全称 RADOS block device,是 Ceph 对外提供的块设备服务。
RGW
RGW 全称 RADOS gateway,是 Ceph 对外提供的对象存储服务,接口与 S3 和 Swift 兼容。
CephFS
CephFS 全称 Ceph File System,是 Ceph 对外提供的文件系统服务。
相关组件
OSD
Ceph OSD(Object-based Storage Device)是一个运行在每个存储节点上的进程,负责管理RADOS中的对象存储和数据副本的复制。每个Ceph OSD都可以处理读取和写入请求,并保持与其他OSD节点之间的数据同步。一个 Ceph 集群一般都有很多个 OSD。
Monitor
Ceph Monitor用于管理和监控Ceph集群的状态、健康状况和配置信息。它是一个运行在独立的服务器上的进程,通常建议在集群中部署3个或以上的Monitor实例以保证高可用性。
Ceph Monitor主要负责以下任务:
- 集群监控和管理:Ceph Monitor可以实时监控集群中的状态和健康状况,包括存储节点的在线状态、磁盘使用情况、数据副本数等。它还可以处理集群中的故障,如处理掉线的存储节点或OSD。
- 集群状态同步:Ceph Monitor会维护一个集群状态的数据库,并将状态信息发送给其他Ceph组件,如OSD和MDS。这样可以保证整个集群中所有的组件都具有相同的状态视图,以便它们能够协同工作。
- 配置管理:Ceph Monitor还负责管理集群的配置信息,包括节点、用户、权限等。管理员可以使用Ceph Monitor来管理和修改集群的配置,以满足不同的需求。 总之,Ceph Monitor是Ceph分布式存储系统中非常重要的组件,它能够确保整个集群的状态和配置保持一致,并能够及时响应故障,保证Ceph集群的高可用性和可靠性。
MDS
MDS (Metadata Server) 用于管理文件系统的元数据。元数据是文件系统中关于文件、目录和权限等信息的描述。Ceph MDS 通常运行在一个或多个专用的计算机上,可以支持高度并发的元数据操作。
MDS 可以实现多个客户端同时访问同一个文件系统,并且可以提供高可用性和容错能力。当一个 MDS 服务器出现故障时,Ceph 可以自动地将其上的元数据转移到其他可用的 MDS 服务器上。
MDS 的设计目标是支持大规模、高并发的文件系统操作。它使用了一些优化技术来提高元数据的访问效率,如 B+ 树索引、目录缓存、异步 I/O 等。这些技术可以使 Ceph MDS 能够快速地处理大量的文件和目录,从而提供良好的性能和可伸缩性。
工作原理
数据存储过程

无论使用哪种存储方式(对象、块、挂载),存储的数据都会被切分成对象(Objects)。(Objects size大小通常为2M或4M)。
每个对象都有一个唯一的 OID(由ino和ono组成),ino是文件的File ID,用于在全局唯一标识每个文件。ono是分片编号。oid可以唯一标识每个不同的对象。
对象并不会直接存储在 OSD 中,因为对象的 size 很小,在一个大规模的集群中可能有几百到几千万个对象。遍历寻址速度很慢;如果将对象通过某种固定哈希映射到OSD中,当OSD损坏时,对象也无法自动迁移到其他OSD上(哈希映射不允许)。因此引入了归置组的概念,即PG。
PG是一个逻辑概念,在数据寻址时类似于数据库中的索引:每个对象都会固定映射进一个PG中,当寻找一个对象时,只需要先找到对象所属的PG,然后遍历PG即可,无需遍历所有对象。数据迁移时,也是以PG作为基本单位进行迁移,Ceph不会直接操作对象。
对象映射到PG:
首先使用静态hash函数对OID做哈希取出特征码,用特征码于PG的数量取模得到序号就是PGID,这种设计方式PG的数量多寡直接决定了数据分布的均匀性,所以合理设置PG数量可以很好地提升集群的性能并使数据均匀分布。
最后PG会根据管理员设置的副本数量进行复制,然后通过CRUSH算法存储到不同的OSD节点上(第一个为主节点,其余为从节点)。
映射关系
- file到object:存储一个file需要将其切分成若干个object。(object的size由RADOS配置)
- object到PG:每个object都会被映射(哈希)到一个PG中,然后以PG为单位进行副本备份、进一步映射到具体的OSD上。
- PG到OSD:通过CRUSH算法来实现,PG会最终存储到 r(设置的冗余存储个数)个OSD上。
RADOS系统逻辑结构
RADOS能够在动态变化和异质结构的存储设备机群之上提供一种稳定、可扩展、高性能的单一逻辑对象(Object)存储接口和能够实现节点的自适应和自管理的存储系统。
服务端 RADOS 集群主要由两种节点组成:
- 多个负责数据存储和维护功能的OSD(Object Storage Device)
- 若干个负责系统状态检测和维护的monitor(至少一个推荐3个起)
Cluster Map
Ceph集群通过monitor集群操作cluster map来实现集群成员的管理。
cluster map 描述了哪些OSD被包含进存储集群以及所有数据在存储集群中的分布。cluster map不仅存储在monitor节点,还被复制到集群中的每一个存储节点,以及和集群交互的client。
当发生设备崩溃、数据迁移等,cluster map内容需要变更时,cluster map的版本号会增加,使通信双方确认自己的map是否需要更新,然后进行后续操作。
RADOS也通过cluster map实现存储半自动化的功能,cluster map会被复制到集群中的其它节点,如存储节点、控制节点,甚至是客户端等。通过在每个存储节点存储完整的Cluster map,来进行数据备份、更新,错误检测、数据迁移等等操作。减轻了 monitor cluster(少部分节点)的负担。
存储方式
对象网关radosgw
Ceph对象网关是一个对象存储接口,建立在该对象之上,librados为应用程序提供了通往Ceph存储集群的RESTful网关接口。Ceph支持两个接口:
- 与S3兼容:为对象存储功能提供Amazon S3 RESTful API的大部分子集兼容的接口。
- 兼容Swift:通过与OpenStack Swift API的大部分子集兼容的接口,为对象存储功能提供支持。 Ceph对象存储使用Ceph对象网关守护进程(radosgw)用于与集群进行交互的HTTP服务器。由于其提供与OpenStack Swift和Amazon S3兼容的接口,因此Ceph对象网关具有其自己的用户管理。Ceph对象网关可以将数据存储在Ceph文件系统客户端或Ceph块设备客户端的同一Ceph存储集群中。S3和Swift API共享一个公共的名称空间,因此可以额使用一个API编写数据、另一个检索数据。

Ceph文件系统
Ceph文件系统(Ceph FS)是个POSIX兼容的文件系统,使用Ceph存储集群来存储数据。Ceph文件系统与Ceph块设备、同时提供S3和Swift API的Ceph对象存储、原生库(librados)都是用着相同的Ceph存储集群系统。
Ceph块存储
块是一个字节序列(如一个512字节的数据块)。基于块的存储接口是最常见的存储数据方法,它们基于旋转介质像硬盘、CD、软盘。各种块设备接口使虚拟块设备成为与Ceph这种海量存储系统交互的理想之选。
Ceph块设备是精简配置的、大小可调且将数据条带化存储到集群内的多个OSD。Ceph块设备利用RADOS的多种能力,如快照、复制和一致性。Ceph的RADOS块设备(RBD)使用内核模块或librbd库与OSD交互。

Reference
11 - 分布式
Introduction
分布式
11.1 - Zookeeper基本使用
安装
单机坏境安装
1、安装jdk
2、上传压缩包到linux系统
| |
3、解压缩
| |
4、进去zookeeper-xx目录,创建data文件夹
| |
5、修改zoo.cfg中的data属性
| |
集群环境安装
通常搭建伪集群,用端口进行区分
准备工作
1、安装JDK
2、上传压缩包
3、将zookeeper解压到/usr/local/zkcluster,复制3份文件,并分别创建data目录,将conf下zoo_sample.cfg复制三份文件并改名为zoo1.cfg、zoo2.cfg、zoo3.cfg
| |
4、在解压后的zookeeper目录下创建data目录,并分别创建三个子目录data1、data2、data3 5、修改zoo.cfg中的data属性和端口信息
| |
配置集群 (1) 在每个zookeeper的data目录下创建一个myid文件, 内容分别是1、2、3。这个文件就是记录
每个服务器的ID
(2) 在每一个zookeeper的zoo. cfg配置客户端访问端口〔(clientPort) 和集群服务器IP列表。
| |
启动集群 依次启动三个zk实例,其中有一个leader和两个follower
| |
zookeeper基本使用
数据结构
Zookeeper数据模型的结构与Unix文件系统很类似,整体上可以看做是一棵树,每个节点称作一个ZNode,每个ZNode都可以通过其路径唯一标识

ZNode节点类型
持久节点
持久化目录节点(PRESISTENT)
客户端与zookeeper断开连接后,该节点依旧存在
持久顺序节点
持久化顺序编号目录节点(PRESISTENT_SEQUENTIAL)
客户端与zookeeper断开连接后,该节点依旧存在,Zookeeper会给该节点按照顺序编号
临时节点
临时目录节点(EPHENERAL)
客户端与zookeeper断开连接后,该节点被删除
临时顺序节点
临时顺序编号目录节点(EPHENERAL_SEQUENTIAL)
客户端与zookeeper断开连接后,该节点被删除,Zookeeper会给该节点按照顺序编号
实战经验:
当被用作注册中心时,对于服务节点是永久节点,服务下的机器是临时节点

命令行使用
| |
API使用
导入依赖包
| |
Expand/Collapse Code Block
| |
zookeeper应用场景
配置中心
在平常的业务开发过程中, 我们通常需要将系统的一些通用的全局配置, 例如机器列表配置, 运行时开关配置, 数据库配置信息等统一集中存储, 让集群所有机器共享配置信息, 系统在启动会首先从配置中心读取配置信息, 进行初始化。传统的实现方式将配置存储在本地文件和内存中, 一旦机器规模更大, 配置变更频繁情况下, 本地文件和内存方式的配置维护成本较高, 使用zookeeper作为分布式的配置中心就可以解决这个问题。
我们将配置信息存在zk中的一个节点中, 同时给该节点注册一个数据节点变更的watcher监听, 一旦节点数据发生变更, 所有的订阅该节点的客户端都可以获取数据变更通知。
负载均衡
Nginx,如果服务器列表产生更新,如何让反向代理服务器知道服务器列表的变更,从而更新负载均衡算法。
建立server节点, 并建立监听器监视servers子节点的状态(用于在服务器增添时及时同步当前集群中服务器列表) 。在每个服务器启动时, 在Servers节点下建立具体服务器地址的子节点, 并在对应的字节点下存入服务器的相关信息。这样, 我们在zookeeper服务器上可以获取当前集群中的服务器列表及相关信息, 可以自定义一个负载均衡算法, 在每个请求过来时从zookeeper服务器中获取当前集群服务器列表, 根据算法选出其中一个服务器来处理请求。
命名服务
命名服务是分布式系统中的基本功能之一。裤命名的实体通常可以是集群中的机器、提供的服务地址或者远程对象, 这些都可以称作为名字。常见的就是一些分布式服务框架(RPC、RMI) 中的服务地址列表, 通过使用名称服务客户端可以获取资源的实体、服务地址和提供者信息。命名服务就是通过一个资源引用的方式来实现对资源的定位和使用。在分布式环境中, 上层应用仅仅需要一个全局唯一名称, 就像数据库中的主键。
在单库单表系统中可以通过自增ID来标识每一条记录, 但是随着规模变大分库分表很常见, 那么自增ID有仅能针对单一表生成ID, 所以在这种情况下无法依靠这个来标识唯一D。UUID就是一种全局唯一标识符。但是长度过长不易识别。
DNS服务
1、域名配置
在分布式系统应用中, 每一个应用都需要分配一个域名, 日常开发中, 往往使用本地HOST绑定域名解析, 开发阶段可以随时修改域名和IP的映射, 大大提高开发的调试效率。如果应用的机器规模达到一定程度后, 需要频繁更新域名节点来进行域名配置, 需要在规模的集群中变更, 无法保证实时性。所有我们在zk上创建一个节点来进行域名配置
2、域名解析
应用解析时, 首先从zk域名节点中获取域名映射的IP和端口。
3、域名变更
每个应用都会在在对应的域名节点注册一个数据变更的watcher监听, 一旦监听的域名节点数据变更, zk会向所有订阅的客户端发送域名变更通知。
4、集群管理
1、集群控制:对集群中节点进行操作与控制
2、集群监控:对集群节点运行状态的收集
在日常开发和运维过程中, 我们经常会有类似于如下的需求:
1)希望知道当前集群中究竟有多少机器在工作。
2)对集群中每台机器的运行时状态进行数据收集。
3)对集群中机器进行上下线操作
在传统的基于Agent的分布式集群管理体系中, 都是通过在集群中的每台机嘴上部罪一个Agent, 由这个Agent负责主动向指定的一个监控中心系统(监控中心系统负责案所有数据进行集中处理, 形成一系列报表, 并负责实时报警, 以下简称“监控中心“) 汇报自己所在机器的状态。在集群规模适中的场景下, 这确实是一种在生产实践中广泛使用的解决方案, 能够快速有效地实现分家集群监控, 但是一旦系统的业务场景增多, 集群规模变大之后, 该解决方案的弊端也就显现出来了。

弊端:大规模升级困难(Agent也需要升级)、编程语言多样性
随着分布式系统规模日益扩大,集群中机器的数量越来越多。有效的集群管理越来越重要,zookeeper集群管理主要利用了watcher机制和创建临时节点来实现。以机器上下线和机器监控为例:
1、机器上下线
新增机器的时候, 将Agent部署到新增的机器上, 当Agent部署启动时, 会向zookeeper指定的节
点下创建一个临时子节点, 当Agent在zk上创建完这个临时节点后, 当关注的节点
zookeeperymachines下的子节点新加入新的节点时或删除郭会发送譬知, 这样就对机器的上下线进行监控。
2、机器监控
在机器运行过程中, Agent会定时将主机的的运行状态信息写入到/ machines/ host主机节点, 监控中心通过订阅这些节点的数据变化来获取主机的运行信息。
Reference
11.2 - zookeeper启动源码分析
zookeeper github地址:https://github.com/apache/zookeeper
本文拉取分支后以3.5.7版本为例
| |
基本知识
1、zk中的数据模型是一棵树 DataTree,每个节点叫做 DataNode
2、zk集群中的 DataTree 时刻保持状态同步
3、Zookeeper 集群中的每个zk节点中,数据在内存和磁盘中都有一份完整的数据。
- 内存数据:DataTree
- 磁盘数据:快照文件 + 编辑日志
总体流程

启动入口
根据bin目录下的启动脚本zkServer.sh中加载启动类QuorumPeerMain类
| |
然后进入到 initailizeAndRun方法中Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// org.apache.zookeeper.server.quorum.QuorumPeerMain#initializeAndRun
// 1.加载配置文件
QuorumPeerConfig config = new QuorumPeerConfig();
if (args.length == 1) {
// 解析参数
config.parse(args[0]);
}
// 2.启动定时清除任务,主要清除旧的快照和日志文件,默认关闭
DatadirCleanupManager purgeMgr = new DatadirCleanupManager(config
.getDataDir(), config.getDataLogDir(), config
.getSnapRetainCount(), config.getPurgeInterval());
purgeMgr.start();
// 3.启动zk zookeeper启动方式分为两种:单机启动和集群启动
if (args.length == 1 && config.servers.size() > 0) {
runFromConfig(config);
} else {
LOG.warn("Either no config or no quorum defined in config, running "
+ " in standalone mode");
// there is only server in the quorum -- run as standalone
// 单机启动
ZooKeeperServerMain.main(args);
}
解析参数
这里path默认是 zoo.cfg,可以查看启动脚本
| |
parseProperties函数中可以看到常用的属性值,这里重点介绍其调用的setupQuorumPeerConfig方法
setupQuorumPeerConfig
这里setupMyId方法初始化了服务的serverId
| |
过期快照删除
任务启动
| |
定时任务
| |
小结
| |
通信初始化
Expand/Collapse Code Block
| |
创建工厂
Expand/Collapse Code Block
| |
查找zookeeperAdmin.md文件
| |
定义了 serverCnxnFactory 为 NIOServerCnxnFactory
工厂配置
| |
启动zk
总体流程

启动 zk 代码在 runFromConfig中
| |
启动函数
| |
加载数据
| |
恢复快照
Expand/Collapse Code Block
| |
更详细代码不一一贴出,可自行查看
恢复日志
Expand/Collapse Code Block
| |
处理DataTree的事务
Expand/Collapse Code Block
| |
processTxn
Expand/Collapse Code Block
| |
createNode
| |
选举准备
基本概念
SID:服务器ID,用来标识唯一一台ZooKeeper集群中的机器,每台机器不能重复,和myid一致。
ZXID:事务ID。ZXID是一个事务ID,用来标识一次服务器状态的变更。在某一个时刻,集群中的每台机器ZXID值不一定完全一致,这和ZooKeeper服务器对于客户端“更新请求”的处理逻辑有关。
Epoch:每个Leader任期的代号。没有Leader时统一轮投票过程中的逻辑时钟值是相同的。每投完一次票这个数据就会增加
一般投票是 (EPOCH, ZXID, SID) 【任期,事务ID,服务器ID】
选举Leader规则:
1、EPOCH 大的直接胜出
2、EPOCH 相同,事务id大的胜出
3、事务id相同,服务器id大的胜出
基本流程

开始选举准备
接上,加载完数据之后,开始Leader选举
| |
开始Leader选举准备
Expand/Collapse Code Block
| |
创建选票实例
Expand/Collapse Code Block
| |
创建CnxnManage
负责选举过程中网络通信
| |
new QuorumCnxManager创建各种队列
启动监听线程
client = ss.accept()
阻塞,等待请求处理
准备开始选举
初始化各种队列
| |
Leader选举
整体结构

基本流程

开始选举执行
入口在上一节 开始Leader选举准备的 QuorumPeer 的 start方法中
| |
因为 QuorumPeer -> ZooKeeperThread -> Thread,所以super.start方法执行的是 QuorumPeer 的run方法。直接看run方法。
| |
lookForLeader
| |
lookForLeader方法中,有两个变量, 一个recvset, 用来保存当前server的接受其他server的本轮投票信息, key为当前server的id, 也即是我们在配置文件中配置的myid, 而另外一个变量outofelection保存选举结束以后法定的server的投票信息, 这里的法定指的是FOLLOWING和LEADING状态的server, 不包活OBSERVING状态的server。 更新逻辑时钟,此处逻辑时钟是为了在选举leader时比较其他选票中的server中的epoch和本地谁最新, 然后将自己的选票proposal|发送给其他所有server。
选出的选票信息封装在一个Notification对象中,如果取出的选票为null我们通过
QuorumCnxManager 检查发送队列中是否投递过选票, 如果投递过说明连接并没有断开,则重新发送选票到其他sever,否则说明连接断开,重连所有Server即可。那么连接没有断开,为什么会收不到选票信息呢,有可能是选票超时时限导致没有收到选票, 所有将选票时限延长了一倍。
如果选出的选票Notification不为null, 校验投票server和选举leader是否合法, 然后根据选票状态执行不同分支, 选举过程定LOOKING分支, 接下来比较选票epoch和当前逻辑时钟。
如果选票epoch > 逻辑时钟, 说明选票是最新的, 自己的选票这一轮已经过时, 应该更新当前自己 Server的逻辑时钟, 并清空当前收到的其他server的选票, 然后比较自己和选票中谁更适合做leader, 发送新的投票给其他所有server。
updateProposal
sendNotifications
| |
此方法遍历所有投票参与者集合,将选票信息构造成一个ToSend对象,分别发送消息放置到队列 sendqueue 中。同理集群中每一个server节点都会将自己的选票发送给其他servgqr。既然有发送选票,肯定存在接受选票信息。下文 WorkerRecv将介绍
WorkerSender发送消息
上文将发送消息放置到队列 sendqueue 中后,由WorkerSender发送消息。它是一个线程,直接看run方法
Expand/Collapse Code Block
| |
处理消息 process方法
| |
manager.toSend方法Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// org.apache.zookeeper.server.quorum.QuorumCnxManager#toSend
public void toSend(Long sid, ByteBuffer b) {
/*
* If sending message to myself, then simply enqueue it (loopback).
*/
if (this.mySid == sid) {
b.position(0);
addToRecvQueue(new Message(b.duplicate(), sid));
} else {
ArrayBlockingQueue<ByteBuffer> bq = new ArrayBlockingQueue<ByteBuffer>(
SEND_CAPACITY);
ArrayBlockingQueue<ByteBuffer> oldq = queueSendMap.putIfAbsent(sid, bq);
if (oldq != null) {
addToSendQueue(oldq, b);
} else {
addToSendQueue(bq, b);
}
// 同对应的服务建议连接
connectOne(sid);
}
}
如果发送的服务器id和myid相同,直接添加到自己的 recvQueue 中。否则往外发送消息,发给谁先发给对应的队列中。
建立连接
connectOne
| |
各种嵌套,这里选择其中同步的建立连接分析Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// org.apache.zookeeper.server.quorum.QuorumCnxManager#startConnection
private boolean startConnection(Socket sock, Long sid)
throws IOException {
xxxx
// If lost the challenge, then drop the new connection
if (sid > self.getId()) {
LOG.info("Have smaller server identifier, so dropping the " +
"connection: (" + sid + ", " + self.getId() + ")");
closeSocket(sock);
// Otherwise proceed with the connection
} else {
SendWorker sw = new SendWorker(sock, sid);
RecvWorker rw = new RecvWorker(sock, din, sid, sw);
sw.setRecv(rw);
SendWorker vsw = senderWorkerMap.get(sid);
if(vsw != null)
vsw.finish();
senderWorkerMap.put(sid, sw);
queueSendMap.putIfAbsent(sid, new ArrayBlockingQueue<ByteBuffer>(
SEND_CAPACITY));
sw.start();
rw.start();
return true;
}
xxx
}
这里如果自己的id小于sid(对方的服务器id),则不发送消息。
SendWorker
因为这也是个线程类,直接找run方法
| |
RecvWorker
同上也是个线程类,直接找run方法
| |
添加消息到recvQueue中
WorkerRecv接受消息
因为该类为线程,直接看其run方法
| |
Follower和Leader状态同步
选举结束后,每个节点都需要根据自己的角色更新自己的状态。
Leader更新状态入口:leader.lead()
Follower更新状态入口:follower.followerLeader()
同步策略:
1、DIFF(差异化同步)
2、TRUNC(回滚同步)
3、SNAP(全量同步)
同步过程中,有可能重复提议或提交
注意事项:
1、follower必须要让leader知道自己的状态:epoch/zxid/sid
2、leader得知follower状态后,要确定以何种方式的数据同步DIFF、TRUNC、SNAP
3、执行数据同步
4、当leader接收到超过半数follower的ack之后,进入正常工作状态,集群启动完成
最终同步的方式:
1、DIFF一样,无需任何动作
2、TRUNC follower的zxid比leader的zxid大,follower要回滚
3、COMMIT leader的zxid比follower的zxid大,发送Proposal给follower提交执行
4、如果follower没有任何数据,直接使用SNAP的方式同步数据(直接把数据全部序列同步给follower)
基本流程

入口
还是在QuorumPeer的run方法中
| |
lead方法
| |
调用线程start方法,直接看本类run方法
| |
followLead方法
| |
服务端Leader启动
基本流程

startZkServer
该方法调用在上文介绍的lead方法中
| |
startup方法
| |
继续分析 setupRequestProcessors,((PrepRequestProcessor)firstProcessor).start();
| |
服务端Follower启动
基本流程

入口在上文分析的followLeader方法中
| |
客户端启动
基本流程

启动入口
客户端是通过 ZkCli.sh 脚本启动的,调用了 org.apache.zookeeper.ZooKeeperMain 函数
| |
ZooKeeperMain
| |
连接zk
| |
创建ZooKeeperAdmin,继续递归super调用Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// org.apache.zookeeper.ZooKeeper#ZooKeeper
public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher,
boolean canBeReadOnly, HostProvider aHostProvider,
ZKClientConfig clientConfig) throws IOException {
LOG.info("Initiating client connection, connectString=" + connectString
+ " sessionTimeout=" + sessionTimeout + " watcher=" + watcher);
if (clientConfig == null) {
clientConfig = new ZKClientConfig();
}
this.clientConfig = clientConfig;
watchManager = defaultWatchManager();
// 设置watcher
watchManager.defaultWatcher = watcher;
ConnectStringParser connectStringParser = new ConnectStringParser(
connectString);
hostProvider = aHostProvider;
// 创建连接
cnxn = createConnection(connectStringParser.getChrootPath(),
hostProvider, sessionTimeout, this, watchManager,
getClientCnxnSocket(), canBeReadOnly);
// 启动
cnxn.start();
}
创建连接
接上文 createConnection
| |
继续找当前类run方法
| |
开始连接
| |
这里注册并连接
处理消息
| |
main.run方法
| |
执行命令
| |
处理命令
| |
processZKCmd即处理服务端发来的命令
11.3 - 分布式一致性算法
基本概念
CAP
C:一致性
A:可用性
P:分区容错性
CP或者AP
BASE 理论
BASE理论指的是基本可用 Basically Available,软状态Soft State,最终一致性 Eventual Consistency,核心思想是即便无法做到强一致性,但应该采用适合的方式保证最终一致性。
BASE,Basically Available Soft State Eventual Consistency 的简写:
BA:Basically Available 基本可用,分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。
S:Soft State 软状态,允许系统存在中间状态,而该中间状态不会影响系统整体可用性。
E:Consistency 最终一致性,系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。
BASE 理论本质上是对 CAP 理论的延伸,是对 CAP 中 AP 方案的一个补充。
XA简单介绍
XA是由X / Open发布的规范,用于DTP(分布式事务处理)。
DTP分布式模型主要含有
- AP: 应用程序
- TM: 事务管理器
- RM: 资源管理器(如数据库)
- CRM: 通讯资源管理器(如消息队列)
XA主要就是TM和RM之间的通讯桥梁。
分布式一致性算法前提
拜占庭将军问题
士兵(信息通道)可以伪造虚假信息
分布式一致性算法考虑的原则
- safety : 在非拜占庭的情况下,不会返回错误的结果
- available: 在大多数server运行且可以和别的server以及client通信的情况下,系统保证可用
- 不依赖时间来保证一致性,时钟错误,信息延迟会导致可用性不好
2PC 二阶段提交
两阶段提交协议(The two-phase commit protocol,2PC)是 XA 用于在全局事务中协调多个资源的机制

1、阶段一提交事务请求
1、协调者向所有的参与者节点发送事务内容,询问是否可以执行事务操作,并等待其他参与者节点的反馈
2、各参与者节点执行事务操作
3、各参与者节点反馈给协调者,事务是否可以执行
2、阶段二事务提交
根据一阶段各个参与者节点反馈的ack如果所有参与者节点反馈ack,则执行事务提交,否则中断事务
事务提交:
1、协调者向各个参与者节点发送commit请求
2、参与者节点接受到commit请求后,执行事务的提交操作
3、各参与者节点完成事务提交后,向协调者返送提交commit成功确认消息
4、协调者接受各个参与者节点的ack后,完成事务commit
中断事务:
1、发送回滚请求
2、各个参与者节点回滚事务
3、反馈给协调者事务回滚结果
4、协调者接受各参与者节点ack后回滚事务
二阶段提交存在的问题:
1、同步阻塞
二阶段提交过程中,所有参与事务操作的节点处于同步阻塞状态,无法进行其他的操作
2、单点问题
一旦协调者出现单点故障,无法保证事务的一致性操作
3、数据不一致
如果分布式节点出现网络分区,标些参与者未收到Commit提交命令。则出现部分参与者完成数据提交。未收到commit的命令的参与者则无法进行事务提交,整个分布式系统便出现了数据不一致性现象。
总结三种场景:协调者挂了;参与者挂了;两者都挂了。问题:不知道执行到哪一步才挂的,事物是否提交。所以多增加一个阶段提交,这样好歹知道是在事物提交之前挂的。
2PC不考虑超时回滚的情况下它是安全的, 2PC可以保证safety但是不保证liveness.
3PC 三阶段提交
3PC是2PC的改进版,实质是将2PC中提交事务请求拆分为两步,形成了anCommit、PreCommit、doCommit三个阶段的事务一致性协议

阶段一: CanCommit
1、事务询问
2、各参与者节点向协调者反馈事务询问的响应
阶段二: PreCcommit
根据阶段一的反馈结果分为两种情况
1、执行事务预提交
1)发送预提交请求
协调者创沂有参与者节点发送preCcommit请求,进入prepared阶段
2)事务预提交
各参与者节点接受到preCommit请求后,执行事务操作
3)各参与者节点向协调者反馈事务执行
2、中断事务
任意一个参与者节点反馈给协调者响应No时,或者在等待超时后,协调者还未收到参与者的反
馈,就中断事务,中断事务分为两步:
1)协调者向各个参与者节点发送abort请求
2)参与者收到abort请求,或者等待超时时间后,中断事务
阶段三: doCommit
1、执行提交
- 发送提交请求
协调者向所有参与者节点发送doCommit请求
- 事务提交
各参与者节点接受到doCommit请求后,执行事务提交操作
- 反馈事务提交结果
各参与者节点完成事务提交以后,向协调者发送ack
- 事务完成
协调者接受各个参与者反馈的ack后,完成事务
2、中断事务
参与者接受到abort请求后,执行事务回滚
参与者完成事务回滚以后,向协调者发送ack
协调者接受回滚ack后,回滚事务
三阶段解决的问题
1、引入超时时间,解决了同步阻塞
2、参与者长时间接收不到协调者响应,就会将事务进行提交,使用这个机制解决了单点问题
3PC是2PC的改进版, 实质是将2PC中提交事务请求拆分为两步, 形成了CanCommit、PreCommit、doCommit三个阶段的事务一致性协议(减少同步阻塞的发生范围)
简单概括一下就是,如果挂掉的那台机器已经执行了commit,那么协调者可以从所有未挂掉的参与者的状态中分析出来,并执行commit。如果挂掉的那个参与者执行了rollback,那么协调者和其他的参与者执行的肯定也是rollback操作。
所以,再多引入一个阶段之后,3PC解决了2PC中存在的那种由于协调者和参与者同时挂掉有可能导致的数据一致性问题。
3PC存在的问题
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。
所以,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况
PC的应用
注意
一般的主从同步并不涉及相关的分布式协议,基本都是异步使用日志或镜像进行主从同步,达成最终一致性。比如:DHFS的namenode,redis的主从同步以及MySQL的主从同步。不要把2pc,3pc,paxos等分布式一致性算法想象为处理主从复制这个过程的算法。
mysql的2pc应用
参考:https://www.cnblogs.com/hustcat/p/3577584.html
flink的2pc应用
参考:https://www.cnblogs.com/zhipeng-wang/p/14082806.html
补偿事务(TCC)
TCC(Try-Confirm-Cancel)又称补偿事务。其核心思想是:"针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)"。它分为三个操作:
- Try阶段:主要是对业务系统做检测及资源预留。
- Confirm阶段:确认执行业务操作。
- Cancel阶段:取消执行业务操作。
TCC是应用层的2PC实现
TCC事务的处理流程与2PC两阶段提交类似,不过2PC通常都是在跨库的DB层面,而TCC本质上就是一个应用层面的2PC,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。
而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。
TCC的具体原理图如👇:

2PC比较:数据库VS应用
比较容易取得共识的结论:不同业务系统之间使用2PC。
那剩下的问题就简单了, 相同业务系统之间是使用数据访问层2PC还是TCC?一般而言,基于研发成本考虑,会建议:新系统由数据库层来实现统一的分布式事务。
但对热点数据,例如商品(票券等)库存,建议使用TCC方案,因为TCC的主要优势正是可以避免长时间锁定数据库资源进而提高并发性。
2PC、3PC、TCC都是基于XA协议思想,TCC是应用层/业务层,TCC实际上为2PC的变种。
2PC与Paxos
有一种广为流传的观点:"2PC到3PC到Paxos到Raft",即认为:
• 2PC是Paxos的残次版本
• 3PC是2PC的改进
上述2个观点都是我所不认同的,更倾向于如下认知:
• 2PC与Paxos解决的问题不同:2PC是用于解决数据分片后,不同数据之间的分布式事务问题;而Paxos是解决相同数据多副本下的数据一致性问题。例如,UP-2PC的数据存储节点可以使用MGR来管理统一数据分片的高可用
• 3PC只是2PC的一个实践方法:一方面并没有完整解决事务管理器宕机和资源管理器宕机等异常,反而因为增加了一个处理阶段让问题更加复杂
Paxos算法
读音:/pakses/
Paxos算法是LeslieLamport1990年提出的一种一致性算法, 该算法是一种提高分布式系统容错性的一致性算法, 解决了3PC中网络分区的问题, paxos算法可以在节点失效、网络分区、网络延迟等各种异常情况下保证所有节点都处于同一状态, 同时paxos算法引入了“过半“理念, 即少数服从多数原则。
paxos有三个版本:
- BasicPaxos
- MultiPaxos
- FastPaxos
四种角色
在paxos算法中, 有四种种角色, 分别具有三种不同的行为, 但多数情况, 一个进程可能同时充当多种角色。
- client: 系统外部角色, 请求发起者, 不参与决策
- proposer: 提案提议者
- acceptor: 提案的表决者, 即是否accept该提案, 只有超过半数以上的acceptor接受了提案, 该提案才被认为被“选定“
- learners: 提案的学习者, 当提案被选定后, 其同步执行提案, 不参与决策
两个阶段
prepare阶段(准备解决)、accept阶段(同意阶段)
1、prepare阶段
<1> proposer提出一个提案, 编号为N, 发送给所有的acceptor。
<2> 每个表决者都保存自己的accept的最大提案编号maxN, 当表决者收到prepare(N) 请求时, 会比较N与maxN的值, 若N小于maxN, 则提案已过时, 拒绝prepare(N) 请求。若N大于等于maxN, 则接受提案, 并将该表决者曾经接受过的编号最大的提案Proposal(myid, maxN, wvalue) 反馈给提议者: 其中myid表示表决者acceptor的标识id, maxN表示接受过的最大提案编号maxN, value表示提案内容。若当前表决者未曾accept任何提议, 会将proposal(myid, nullnull) 反馈给提议者。
2、accept阶段
<1> 提议者proposa|发出prepare(N) , 若收到超过半数表决者acceptor的反馈, proposal将真正的提案内容proposal(N, wvalue) 发送给所有表决者。
<2> 表决者aCCeptor接受提议者发送的proposal提案后, 会将自己曾经accept过的最大
提案编号maxN和反馈过的prepare的最大编号, 若N大于这两个编号, 则当前表决者accept该提
案, 并反馈给提议者。否则拒绝该提议。
<3> 若提议者没有收到半数以上的表决者accept反馈, 则重新进入prepare阶段, 递增提案编号,
重新提出prepare请求。若收到半数以上的accept, 则其他未向提议者反馈的表决者称为
learner, 主动同步提议者的提案。
正常流程

单点故障,部分节点失败

proposer失败


Basic Paxos算法存在活锁问题(liveness)或全序问题
活锁:并发导致的你让我我让你,如对面走路互相让路还是会冲突。避免活锁:执行时间错开。 注意Basic Paxos的活锁原因是proposer1在发送请求给accepter之后挂掉了,然后proposer2又向accepter发送了新的请求,然后导致的活锁问题。更深层proposer可以有多个,所以mutil paxos采用了leader(单个)替代proposer(多个)解决活锁问题,如果leader挂掉就重新选举,都是leader说了算就不会产生活锁问题了。
消化理解
paxos算法
基于消息传递一关有高度容错性的一种算法, 是目前公认的解决分布式一致性问题最有效的算法
重要概念
半数原则: 少数服从多数
解决问题
在分布式系统中, 如果产生容机或者网络异常情况, 快速的正确的在集群内部对染个数据的信达成一致, 并玟不管发生任何异常, 都不会破坏整个系统的一致性
复杂度问题
为了解决活锁问题,出现了multi-paxos;
为了解决通信次数较多的问题,出现了fast-paxos;
为了尽量减少冲突,出现了epaxos。
可以看到,工业级实现需要考虑更多的方面,诸如性能,异常等等。这也是许多分布式的一致性框架并非真正基于paxos来实现的原因。
全序问题
对于paxos算法来说,不能保证两次提交最终的顺序,而zookeeper需要做到这点。
理解basic paxos细节
- proposer是可以有多个,所以他叫做proposer,而不叫leader
- proposer并不强制要发送propose到全部acceptor,也可以发送70%的acceptor,只要通过的有半数以上,就认为协议是成功的
- 容易看出,第二阶段的时候client仍需要等着,只有在第二阶段大半acceptor返回accepted后,client才能得到成功的信息
- 有一些错误场景中,proposer会互相锁住对方的递交,详细可以看wiki,这里不多阐述:Paxos (computer science)
Multi Paxos
改进点
- Multi Paxos中采用了 Leader election 保证了只有一个Proposer,避免了活锁的问题
- Basic Paxos中只有Proposer知道 选择了哪个value,如果其他server想知道选择了哪个,那么就得按照paxos协议发起请求。
- multi paxos 目标是实现复制日志序列;实现的时候我们在Prepare和accept的时候,加上日志记录的序号即可。
Fast Paxos
在Multi Paxos中,proposer -> leader -> acceptor -> learner,从提议到完成决议共经过3次通信,能不能减少通信步骤?
对Multi Paxos phase2a,如果可以自由提议value,则可以让proposer直接发起提议、leader退出通信过程,变为proposer -> acceptor -> learner,这就是Fast Paxos[2]的由来。
Multi Paxos里提议都由leader提出,因而不存在一次决议出现多个value,Fast Paxos里由proposer直接提议,一次决议里可能有多个proposer提议、出现多个value,即出现提议冲突(collision)。leader起到初始化决议进程(progress)和解决冲突的作用,当冲突发生时leader重新参与决议过程、回退到3次通信步骤。
Paxos自身隐含的一个特性也可以达到减少通信步骤的目标,如果acceptor上一次确定(chosen)的提议来自proposerA,则当次决议proposerA可以直接提议减少一次通信步骤。如果想实现这样的效果,需要在proposer、acceptor记录上一次决议确定(chosen)的历史,用以在提议前知道哪个proposer的提议上一次被确定、当次决议能不能节省一次通信步骤。
EPaxos
除了从减少通信步骤的角度提高Paxos决议效率外,还有其他方面可以降低Paxos决议时延,比如Generalized Paxos[3]提出不冲突的提议(例如对不同key的写请求)可以同时决议、以降低Paxos时延。
更进一步地,EPaxos[4](Egalitarian Paxos)提出一种既支持不冲突提议同时提交降低时延、还均衡各节点负载、同时将通信步骤减少到最少的Paxos优化方法。
为达到这些目标,EPaxos的实现有几个要点:
- 一是EPaxos中没有全局的leader,而是每一次提议发起提议的proposer作为当次提议的leader(command leader);
- 二是不相互影响(interfere)的提议可以同时提交;
- 三是跳过prepare,直接进入accept阶段。
ZAB协议
由于paxos算法窍现起来较难, 存在活锁和全序问题(无法保证两次最终提交的顺序) , 所以zookeeper并没有使用paxos作为一致性协议, 而是使用了ZAB协议。
ZAB(zookeeper atomic broadcast) : 是一种支持崖溃恢复的原子广播协议, 基于multi paxos实现
ZooKeeper使用单一主进程Leader用于处理客户端所有事务请求, , 即写请求。当服务器数据发生变更好, 集群采用ZAB原子广播协议, 以事务提交proposal的形式广播到所有的副本进程, 每一个事务分配一个全局的递增的事务编号xid。
若客户端提交的请求为读请求时, 则接受请求的节点直接根据自己保存的数据响应。**若是写请求, 且当前节点不是leader, 那么该节点就会将请求转发给leader, leader会以提案的方式广播此写请求, 如果超过半数的节点同意写请求, 则该写请求就会提交。**leader会通知所有的订阅者同步数据。

三种角色
leader 负责处理集群的写请求,并发起投票,只有超过半数的节点同意后才会提交该写请求
follower 处理读请求,响应结果。转发写请求到leader,在选举leader过程中参与投票
observer 可以理解为没有投票权的follower,主要职责是协助follower处理读请求。那么当整个zk集群读请求负载很高时(此时会使用observer缓解读请求压力),为什么不直接增加follow节点而是增加observer节点呢?原因是增加follower节点会让leader在提出写请求提案时,需要半数以上的follower投票节点同意,这样会增加leader和foloower的通信压力,降低写操作效率
两种模式
恢复模式
当服务启动或领导崩溃后, zk进入恢复状态, 选举leader, leader选出后, 将完成leader别其他机器的数据同步, 当大多数server完成和leader的同步后, 恢复模式结束
广播模式
一旦Leader已经和多数的Follower进行了状态同步后, 进入广播模式。进入广播模式后, 如果有
新加入的服务器, 会自动从leader中同步数据。leader在接收客户端请求后, 会生成事务提案广播
给其他机器, 有超过半数以上的follower同意该提议后, 再提交事务。
注意在ZAB的事务的二阶段提交中, 移除了事务中断的逻辑, follower要么ack, 要么放弃, leader无需等待所有的follower的ack。
zxid
zxid是64位长度的Long类型, 其中高32位表示纪元epoch, 低32位表示事务标识xid。即zxid由两部分构成: epoch和xid
每个leader都会具有不同的epoch值, 表示一个纪元, 每一个新的选举开启时都会生成一个新的
epoch, 新的leader产生, 会更新所有的zKServer的zxid的epoch, xid是一个依次递增的事务编号。
leader选举算法
三个核心选举原则
1、集群中只有超过了半数以上的服务器启动,集群才能正常工作
2、在集群正常工作之前,myid小的服务器会给myid大的服务器进行,持续到集群正常工作,选出leader
3、选择leader之后,之前的服务器状态由looking改变为following,以后的服务器都是follower

启动过程
- 每一个server发出一个投票给集群中其他节点
- 收到各个服务器的投票后, 判断该投票有效性, 比如是否是本轮投票(解决活锁问题), 是否是looking状态
- 处理投票, pk别人的投票和自己的投票比较规则xid > myid“取大原则“
- 统计是否超过半数的接受相同的选票
- 确认leader, 改变服务器状态
- 添加新server, leader已经选举出来, 只能以follower身份加入集群中
崩溃恢复过程
- leader挂掉后, 集群中其他follower会将状态从FOLLOWING变为LOOKING, 重新进入leader选举
- 同上启动过程
消息广播算法
一旦进入广播模式, 集群中非leader节点接受到事务请求, 首先会将事务请求转发给服务器, leader服务器为其生成对应的事务提案proposal,并发送给集群中其他节点,如果过半则事务提交;

- leader接收到消息后, 消息通过全局唯一的64位自增事务id,zxid标识
- leader发送给follower的提案是有序的, leader会创建一个FIFFO队列, 将提案顺序写入队列中发送给follower
- follower接受到提案后, 会比较提案zxid和本地事务日志最大的zxid, 若提案zxid比本地事务id大(保证全序), 将提案记录到本地日志中, 反馈ack给leader, 否则拒绝
- leader接收到过半ack后, leader向所有的follower发送commit, 通知每个follower执行本地事务
ZAB与Paxos比较
ZAB和Paxos最大的不同是,ZAB主要是为分布式主备系统设计的,而Paxos的实现是一致性状态机(state machine replication)
尽管ZAB不是Paxos的实现,但是ZAB也参考了一些Paxos的一些设计思想,比如:
- leader向follows提出提案(proposal)
- leader 需要在达到法定数量(半数以上)的follows确认之后才会进行commit
- 每一个proposal都有一个纪元(epoch)号,类似于Paxos中的选票(ballot)
Reference
11.4 - Quorum机制
简介
Quorum(多数派)机制,是一种分布式系统中常用的,用来保证数据冗余和最终一致性的投票算法,其主要数学思想来源于鸽巢原理。
除了 Quorum机制 以外,最简单的机制是采用主从复制机制。
WARO机制
分布式系统利用多台计算机协同解决单台计算机无法解决的计算存储等问题,但系统运行过程中,网络异常、节点宕机等异常情况的发生几乎是避免不了的。
为了保证系统正常运行,提供可靠服务,分布式系统对于数据的存储采用了多份数据副本来保证可靠性。副本不仅可以用于备份,还可以提供服务。
副本的引入带来的问题是怎么保证每个节点的数据一致性。
最简单的方法就是 WARO(Write All Read Only)机制。WARO是一种简单的副本控制协议。当客户端请求向某副本更新数据时,只有当所有的副本都更新成功之后,这次写操作才算成功,否则视为失败。而读取数据时,只需要查询其中一个副本数据就可以返回。
WARO机制对于频繁更新数据的系统不够友好,写操作时延现象非常明显,在并发较高或连续执行等场景下效率更低。可以看出写服务和读服务承受的压力不均衡, WARO牺牲了写服务的可用性,最大程度地增强了读服务的可用性。而 Quorum 就是写服务和读服务之间进行一个折中。
Quorum机制
介绍
分布式系统中的每一份数据拷贝对象都被赋予一票。每一个读操作获得的票数必须大于最小读票数(read quorum)(Vr),每个写操作获得的票数必须大于最小写票数(write quorum)(Vw)才能读或者写。如果系统有V票(意味着一个数据对象有V份冗余拷贝),那么最小读写票数(quorum)应满足如下限制:
- Vr + Vw > V
- Vw > V/2 第一条规则保证了一个数据不会被同时读写。当一个写操作请求过来的时候,它必须要获得Vw个冗余拷贝的许可。而剩下的数量是V-Vw 不够Vr,因此不能再有读请求过来了。同理,当读请求获得了 Vr 个冗余拷贝的许可时,写请求就无法获得许可。
第二条规则保证了数据的串行化修改。一份数据的冗余拷贝不可能同时被两个写请求修改。
机制分析
无法保证强一致性
强一致性就是:任何时刻任何用户或节点都可以读到最近一次成功提交的副本数据。强一致性是程度最高的一致性要求,也是实践中最难实现的。
读取最新的数据:
因此当读取数据时,如果读取到数据版本较低不满足要求时,需要继续再去其它节点上读取数据。在已经知道最近成功提交的数据版本号的前提下,最多读 Vr 个副本就可以读到最新数据了。
如何确定读到最高版本号的数据:继续读副本,直到读到的最高版本号副本出现了 Vw 次。
11.5 - Raft-01.基础理论
简介
Raft 算法可以说是目前最成功的分布式共识算法,包括 TiDB、FaunaDB、Redis 等都使用了这种技术。原因是 Multi-Paxos 没有具体的实现细节,虽然它给了开发者想象空间,但共识算法一般居于核心位置,一旦存在潜在问题必然带给系统灾难性的后果。而 Raft 算法给出了大量的实现细节,且处理方式相比于 Multi-Paxos 有两点优势。
相关术语
| 英文 | 中文 |
|---|---|
| Term | 选举任期,每次选举之后递增1 |
| Vote | 选举投票(的ID) |
| Entry | Raft算法的日志数据条目 |
| candidate | 候选人 |
| leader | 领导者 |
| follower | 跟随者 |
| commit | 提交 |
| propose | 提议 |
Raft 基本原理
对比Paxos
Zookeeper的ZAB,Viewstamped Replication(VR),raft,multi-paxos,这些都可以被称之为Leader-based一致性协议。不同的是,multi-paxos leader是作为对经典 paxos 的优化而提出,通过选择一个 proposer 作为 leader 降低多个 proposer 引起冲突的频率,提升性能将一次决议的平均消息代价缩小到最优的两次,实际上就算有多个leader存在,算法还是安全的,只是退化为经典的paxos算法。
相同点:
1、Multi-paxos和Raft都用一个数字来标识leader的合法性,multi-paxos中叫proposer-id,Raft叫term
2、法定数量(半数以上)的follows确认之后才会进行commit
不同点:
1、proposer & leader
Paxos 的 proposer是可以有多个,所以他叫做proposer,而不叫 Leader。Raft协议比Paxos的优点是 容易理解,容易实现。它强化了leader的地位,把整个协议可以清楚的分割成两个部分,并利用日志的连续性做了一些简化:
(1)Leader在时。由Leader向Follower同步日志
(2)Leader挂掉了,选一个新Leader,Leader选举算法。
2、日志的连续性
Raft协议强调日志的连续性,multi-paxos 则允许日志有空洞。 日志的连续性蕴含了这样一条性质:如果两个不同节点上相同序号的日志,term相同,那么这和这之前的日志必然也相同的。
Raft 可以看成是 Multi-Paxos 的改进算法。相比而言 Raft 算法给出了大量的实现细节,且处理方式相比于 Multi-Paxos 有两点优势。
- 发送的请求的是连续的,也就是说 Raft 的写日志操作必须是连续的;而 Multi-Paxos 可以并发修改日志,这也体现了“Multi”的特点。
- 选主必须是最新、最全的日志节点才可以当选,这一点与 ZAB 算法有相同的原则;而 Multi-Paxo 是随机的。因此 Raft 可以看成是简化版本的 Multi-Paxos,正是这个简化,造就了 Raft 的流行。
定义问题
- 输入:输入命令
- 输出:所有节点最终处于相同状态
- 约束
- 网络不确定性:在非拜占庭情况下(即非受信网络),出现网络 分区/冗余/丢失/乱序 等问题下要保证数据状态正确
- 基本可用性:集群中大部分节点能够保持相互通信,集群就应该能够正确响应客户端
- 不依赖时序:不依赖物理时钟或极端的消息延迟来保证一致性
- 快速响应:对客户端请求的响应不能依赖集群中最慢的节点
拆解问题
raft 将一致性问题分解为3个子问题:
- 领导者选举
现有领导者失效时,需要选举新的领导者
- 日志复制
领导者需要通过复制保持所有服务器的日志与自己的同步
- 安全性
如果其中一个服务器在特定索引上提交了日志条目,那么其它服务器不能在该索引应用不同的日志条目
一个可行解
- 初始化时有一个领导者节点,负责发送日志到其他跟随者,并决定日志的顺序
- 当读请求到来时,在任意节点都可以读,而写请求只能重定向到领导者处理
- 领导者先写入自身的日志,然后同步给半数以上节点,跟随者成功复制日志,领导者才提交日志
- 日志最终由领导者先按顺序应用于状态机,其它跟随者随机应用到状态机
- 当领导者崩溃后,其它跟随者通过心跳感知并选举出新的领导者继续集群的正常运转
- 当有新的节点加入或退出集群,需要将配置信息同步给整个集群
Raft 选举
状态机
在 raft 算法中,任意时刻每个服务器节点都处于这三个状态之一:
- Follower:追随者
跟随者都是被动的,不会发送任何请求,只是简单的响应来自领导者或候选人的请求
- Candidate:候选人
如果跟随者接收不到消息,那么它就会变成候选人并发起一次选举,获得集群中大多数选票的候选人将成为领导者
- Leader:领导者
系统中只有一个领导者并且其它节点全部都是跟随者,领导者处理所有的客户端请求(如果一个客户端和跟随者联系,那么跟随者会把请求重定向给领导者)

任期
Raft 将时间划分为任意长度的任期,每个任期都以一次选举开始。如果一个候选者赢得选举,在剩下的任期时间内都是领导者。每个任期最多有一个领导者(投票出现分歧则没有领导者)。
任期号单调递增,每个服务器存储当前任期号,并在每次通信中交换该任期编号。
如果一个服务器的当前任期号小于其他服务器,那么它将把当前任期更新为更大的值。如果候选人或领导者发现其任期已过期,则立即转化为追随者状态。如果服务器接收到带有过期任期号的请求,将拒绝请求。
Leader 选举
领导者定期向跟随者发送心跳来维持自己的 Leader 角色。如果跟随者在一定的时间内没有接收到任何的消息,也就是选举超时,那么它就会认为系统中没有可用的领导者,并且发起选举以选出新的领导者。
开始一次选举,跟随者先要增加自己的当前任期号并且转换到候选人状态。然后并行地向集群中的其它服务器节点发送请求投票的 RPCs 来给自己投票。
候选人的选举会有下面三种结果:
1、候选人自己成为 Leader;
2、其他服务成为 Leader
3、候选人没有选出 Leader,可能是多个跟随者同时成为候选人,然后选票被瓜分导致没有候选人能获得最大的票数。
对于选举过程选票被瓜分的情况,Raft 算法使用随机候选超时时间的方法来确保很少会发生选票瓜分的情况,就算发生也能很快地解决。如下选举的随机算法:
- 为了防止选票期初就被瓜分,候选超时时间是从一个固定的区间(如:150-300ms)随机选择;
- 这个候选超时时间就是 Follower 要等待成为 Candidate 的时间;
- 每个候选人在开始一次选举时会重置一个随机候选的时间,也就是 150-300ms 中的随机值;
- 这个时间结束后 Follower 变成 Candidate 开始选举,不同时间苏醒竞争 Leader,苏醒时间早就有竞争优势;
- 这样大大减少选票被瓜分的情况,如果选票还是被瓜分就继续从第 1 步开始。
日志复制
一旦 Leader 被选举成功,就可以对客户端提供服务。客户端提交的每一条命令都会被顺序记录到 Leader 的命令中,每一条命令都包含 Term 编号和顺序索引,然后向其它节点并行发送 AppendEntries RPC 命令用以复制命令(如果命令丢失会不断重发),当大多数节点复制成功后,Leader 就会提交命令,即执行该命令并且将执行结果返回给客户端,Raft 保证已经提交的命令最终也会被其它节点成功执行。
具体流程如下:
- 所有请求都先经过 Leader,每个请求首先以日志的形式保存在 Leader 中,然后日志的状态是 uncommited 状态;
- Leader 将这些更改的请求发送到 Follower;
- Leader 等待大多数的 Follower 确定提交;
- Leader 在等待大多数的 Follower 确定提交后,commit 这些更改,提交这个信息到自己的状态机中,然后通知客户端更新的结果;
- 同时 Leader 会不断的尝试通知 Follower 去存储所有更新的信息。

日志由有序编号(log index)的日志条目组成。每个日志包含它被创建时的任期号(term),和用于状态机执行的命令。如果一个日志条目被复制到大多数服务器上,就被认为可以提交(commit)了。

Raft 日志同步保证如下两点:
- 如果不同日志中的两个条目有相同的索引和任期号,则它们所储存的命令是相同的
- 如果不同日志中的两个条目有相同的索引和任期号,则它们之前的所有日志条目都是相同的 第一条特性源于Leader在一个 term 内在给定的一个 log index 最多创建一条日志条目,同时该条目在日志中的位置也从来不会改变。
第二条特性:Raft算法在发送日志复制请求时会携带前置日志的 term 和 logIndex 值(即 prevLogTerm 和 prevLogIndex),只有在 prevLogTerm 和 prevLogIndex 匹配的情况下才能成功响应请求。如果 prevLogTerm 和 prevLogIndex 不匹配,则说明当前节点可能是新加入的、或者之前服从于其它 Leader,亦或当前节点之前是 Leader 节点。为了兑现承诺二,Leader 节点需要与该 Follower 节点向前追溯找到 term 和 logIndex 匹配的那条日志,并使用Leader节点的日志强行覆盖该 Follower 此后的日志数据。
Raft 详细实现
数据结构

各类状态
通用持久性状态
| 参数 | 解释 |
|---|---|
| currentTerm | 服务器已知最新任期(服务器首次启动时初始化为0,单调递增) |
| votedFor | 当前任期内收到选票的候选者id,如果没有投给任何候选者则为空 |
| log[] | 日志条目;每个条目包含用于状态机的命令,以及领导者接收到该条目时的任期(第一个索引为1) |
通用易失性状态
| 参数 | 解释 |
|---|---|
| commitIndex | 已知已提交的最高的日志条目的索引(初始值为0,单调递增) |
| lastApplied | 已知被应用到状态机的最高的日志条目的索引(初始值为0,单调递增) |
领导者上的易失性状态
| 参数 | 解释 |
|---|---|
| nextIndex[] | 对于每一台服务器,发送到该服务的下一个日志条目的索引(初始值为领导者最后的日志条目的索引+1) |
| matchIndex[] | 对于每一台服务器,已知的已经复制到该服务器的最高日志条目的索引(初始值为0,单调递增) |
RPC
候选人发起选举投票RPC到跟随者或候选人
由领导者发起RPC到跟随者
a. 日志追加
b. 心跳通知
请求投票
- 跟随者变更为候选人后
- 选举超时后
请求参数
| 参数 | 解释 |
|---|---|
| term | 候选人的任期号 |
| candidateId | 请求选票的候选人的id |
| lastLogIndex | 候选人的最后日志条目的索引值 |
| lastLogTerm | 候选人最后日志条目的任期号 |
返回值
| 返回值 | 解释 |
|---|---|
| term | 当前任期号,以便于候选人去更新自己的任期号 |
| voteGranted | 候选人赢得了此张选票时为真 |
追加日志&心跳(领导者调用)
- 客户端发起写命令请求时
- 发送心跳时
- 日志匹配失败时
请求参数
| 参数 | 解释 |
|---|---|
| term | 当前领导者的任期 |
| leaderId | 领导者ID,因此跟随者可以对客户端进行重定向 |
| prevLogIndex | 紧邻新日志条目之前的那个日志条目的索引 |
| prevLogTerm | 紧邻新日志条目之前的那个日志条目的任期 |
| entries[] | 需要被保存的日志条目(被当做心跳使用时 则日志条目内容为空;为了提高效率可能一次性发送多个) |
| leaderCommit | 领导者的已知已提交的最高的日志条目的索引 |
返回值
| 返回值 | 解释 |
|---|---|
| term | 当前任期,对于领导者而言会更新自己的任期 |
| success | 结果为真,如果跟随者所含有的条目和prevLogIndex以及prevLogTerm匹配上了 |
Raft 算法原理与证明
基本原则与特性
选举安全特性
对于一个给定的任期号,最多只会有一个领导人被选举出来
在一个任期内半数以上的票数才能当选,保证每个任期要么0个领导要么1个领导

领导人只附加原则
领导人绝对不会删除或者覆盖自己的日志,只会增加
日志匹配原则
如果两个日志在相同的索引位置的日志条目的任期号相同,那么就认为这个日志从头到这个索引位置之间全部完全相同
- 因为 集群在任意时刻最多有一个 leader 存在,leader在一个任期内只会在同一个索引处写入一次日志
- 又因为 领导者从来不会删除或者覆盖自己的日志,并且日志一旦写入就不允许修改
- 所以 只要任期和索引相同,那么在任何节点上的日志也都相同
- 因为跟随者每次只会从与 leader 的 PreLog 匹配出追加日志,如果不匹配则 nextIndex-1 重试
- 所以 由递归的性质可知一旦跟随者和 leader 在 PreLog 处匹配,那么之前的所有日志就都是匹配的
- 所以 只要把 PreLog 之后的日志全部按此次 Leader 同步 RPC 的日志顺序覆盖即可保证二者的一致性
领导人完全特性
如果某个日志条目在某个任期号中已经被提交,那么这个条目必然出现在更大任期号的所有领导者中
状态机安全特性
如果一个领导者已经将给定的索引值位置的日志条目应用到状态机中,那么其它任何的服务器在这个索引位置不会应用到一个不同的日志
安全性
每一任的领导者是否一定会有所有任期内领导者的全部已提交日志?
选举限制
选民只会投票给任期比自己大,最后一条日志比自己新(任期大于 或 等于时索引更大)的候选人。
这个存在一个问题,如下图示:

- 时刻a,S1 是任期2的领导者并且想部分节点(S1和S2)复制了2号位置的日志条目,然后宕机
- 时刻b,S5 获得了 S3、S4(S5 的日志与 S3 和 S4 的一样新,最新的日志的任期号都是1)和自己的选票赢得了选举,成了3号任期的领导者,并且在2号位置上写入了一个任期号为3的日志条目。在新日志条目复制到其他节点之前,S5 宕机了。
- 时刻c,S1 重启,并且通过 S2、S3、S4 和自己的选票赢得了选举,成了4号任期的领导人,并且继续向 S3 复制2号位置的日志。此时,任期2的日志条目已经在大多数节点上完成了复制。
- 时刻d,S1 发生故障,S5 通过 S2、S3 的选票再次成为领导者(因为 S5 最后一条日制条目的任期号是 3,比 S2、S3、S4 中任意一个节点上日志都更加新些),任期号为5。然后 S5 用自己的本地日志也写了其它节点上的日志
- 上面这个例子生动地说明了,即使日志条目被半数以上的节点写盘(复制)了,也并不代表它已经被提交了(commited)到 Raft 集群了——因为一旦某条日志被提交,那么它将永远没法被删除或修改。这个例子同时也说明了,领导人无法单纯地依靠之前任期的日志条目信息判断它的提交状态
- 因此针对以上场景,Raft算法对日志提交条件增加了一个额外的限制:要求Leader在当前任期至少有一条日志被提交,即被超过半数的节点写盘。
- 正如上图中e描述的那样,S1 作为 Leader,在崩溃之前,将3号位置的日志(任期号为4)在大多数节点上复制了一条日志条目(指的是条目3,term 4),那么即使这时 S1 宕机了,S5 也不可能赢得票。无法赢得选举,这就意味着2号位置的日志条目不会被覆写。 所以新上任的领导者在接受客户端写入命令之前,需要提交一个 no-op(空命令),携带自己任期号的日志复制到大多数集群节点上才能真正的保证选举限制的成立。
状态机安全性证明(三段论)
- 定义 A为上个任期最后一条已提交日志,B为当前任期的 leader
- 因为 A必然同步到了集群中的半数以上节点
- 又因为 B只有获得集群中半数以上节点的选票后才能成为 leader
- 所以 B的选民必然存在拥有A日志的节点
- 又因为 选举限制,B成为leader的前提是比给它投票的所有选民都要新
- 所以 B的日志中必然要包含A
- 又因为 日志完全匹配规则,如果A被包含,那么比A小的所有日志都被B包含
- 因为 lastAppied <= commitIndex
- 又因为 raft保证已提交日志在所有集群节点上的顺序一致
- 所以 应用日志必然在所有节点上顺序一致
- 因为 状态机只能按序执行应用日志部分
- 得证 状态机在整个集群所有节点上必然 最终一致
状态机安全性证明(反证法)
- 当日志条目L被同步给半数以上节点时,leader A会移动 commitIndex 指针提交日志,此时的日志被提交
- 当 leader 崩溃后,由一个新节点成为 leader B,假设 leader B 是第一个未包含 leader A 最后已提交日志的领导者
- 选举过程中,只有获得半数以上节点认可才能成为 leader,因此至少有一个投票给当前 leader B 的节点中含有已经提交的那条日志L。
- 那么根据选举限制,节点只会将选票投给至少与自己一样新的节点
- 节点C作为包含 leader A 最后提交日志条目的投票者,如果 leader B 与节点 C 的最后一条日志的任期号一样大时,节点 C 的条目数一定大于 leader B,因为 leader B 是第一个未包含最后一条 leader A 日志的领导者。这与选举限制相矛盾,节点 C 不会投票给 leader B
- 如果 leader B 最后一条日志的任期号大于节点 C 最后一条日志的任期号,那么 leader B 的前任领导中必然包含了 leader A 已经提交的日志(leader B 是第一个不包含 leader A 已提交日志的领导者 这一假设)。根据日志匹配特性 leader B 也必包含 leader A 最后的已提交日志,这与假设矛盾。
- 所以证明 未来所有的临高这必然包含过去领导者已提交的日志,并且日志匹配原则,所有已提交日志的顺序一定是一致的。
- 又因为 任意节点仅会将已提交日志按顺序应用于自身的状态机,更新 lastApplied 指针,因此所有节点的状态机都会最终顺序一致。
- 得证 raft 算法能够保证节点之间的协同工作。
常见问题
新Leader未同步前任committed数据
问题描述:
Leader 宕机后选出的新 Leader 没有同步前任 committed 数据,新leader节点会强行覆盖集群中其它节点与自己冲突的日志数据。
解决方法:
这种情况 Raft 会对参加选举的节点进行限制,只有包含已经 committed 日志的节点才有机会竞选成功。
- 参选节点的 term 值大于等于投票节点的 term 值
- 如果 term 值相等,则参选节点的 lastLogIndex 大于等于投票节点的 lastLogIndex 值
Leader在将日志复制给Follower节点之前宕机
如果在复制之前宕机,这时消息处于uncommitted状态,新选出的 Leader 一定不包含这些日志信息,所以新的 Leader 会强制覆盖 Follower 中跟自己冲突的日志,也就是刚刚宕机的Leader,如果变成follower,它未同步的信息会被新的 Leader 覆盖掉。
Leader在将日志复制给Follower节点之间宕机
在复制的过程中宕机,会有两种情况:
- 只有少数的 Follower 被同步到了;
如果只有少数的 Follower 被同步了,新的 Leader 不会包含这些信息,新的 Leader 会覆盖那些已经同步的节点的信息。
- 大多数的 Follower 被同步到了。
Leader 在复制的过程中宕机,消息肯定没有 commit ,新的 Leader 需要再次尝试将其复制给各个 Follower 节点,并依据自己的复制状态决定是否提交这些日志。
Leader在响应客户端之前宕机
这种情况根据上面的同步机制可以知道,消息肯定是 committed 状态,新的 Leader 肯定包含这个信息,但是新任 Leader 可能还未被通知该日志已经被提交,不过这个信息在之后一定会被新任 Leader 标记为 committed。
不过对于客户端可能超时拿不到结果,认为本次消息失败了,客户端需要考虑幂等性。
时间和可用性
Raft 的要求之一就是安全性不能依赖时间:整个系统不能因为某些事件运行的比预期快一点或者慢一点就产生了错误的结果。但是可用性(系统可以及时的响应客户端)不可避免的要依赖于时间。
领导人选举是 Raft 中对时间要求最为关键的方面。Raft 可以选举并维持一个稳定的领导人,只要系统满足下面的时间要求:
广播时间(broadcastTime) << 候选超时时间(electionTimeout) << 平均故障间隔时间(MTBF)
工程优化
容错性
- 领导者崩溃通过选举可以解决,但跟随者与候选人崩溃呢?
基础的 raft 算法,通过无限次幂等的附加复制 rpc 进行重试来解决
- 当平均故障时间大于信息交换时间,系统将没有一个稳定的领导者,集群无法工作
广播时间 << 心跳超时时间 << 平均故障时间
- 客户端如何连接 raft 的 server 节点?
客户端随机选择一个节点去访问,如果是跟随者,跟随者会把自己知道的领导者告知客户端
- 领导者提交后返回时崩溃,客户端重试不就导致相同的命令反复执行了吗?
客户端为每次请求标记唯一序列号,服务端在状态中维护客户端最新的序列号标记,进行幂等处理
- 客户端给领导者 set a=3 并进行了提交,此时客户端如果从一个未被同步的节点读取a读不到写后的值
每个客户端应该维持一个 latestIdx 值,每个节点在接受请求的时候与自己的 lastApplied 值比较,如果这个值大于自己的 lastApplied,则拒绝此次请求,客户端重定向到一个 lastApplied 大于等于自己 latestIdx 的请求,并且每次读取请求都会返回这个节点的 lastApplied 值,客户端将 lastestIdx 更新为此值,保证读取的线性一致。
- 如果 leader 被孤立,其它跟随者选举出 leader,但是当前 leader 还是向外提供脏数据怎么办?
写入数据由于无法提交,因此会立即失败,但无法防止读到脏数据。 解决办法是:心跳超过半数失败,leader 感知到自己处于少数分区而被孤立进而拒绝提供读写服务
- 当出现网络分区后,被孤立少数集合的节点无法选举,只会不断地增加自己的任期,分区恢复后由于失联的节点任期更大,会强行更新所有节点的任期,触发一次重新选举,而又因为其日志不够新,被孤立的节点不可能成为新的 leader 。所以其状态机是安全的,只是触发了一次重新选举,使得集群有一定时间的不可用。这是完全可以避免的。
在跟随者成为候选人时,先发送一轮 pre-vote rpc 来判断自己是否在大多数分区内(是否有半数节点回应自己),如果是则任期加 1 进行选举。否则的话就不断尝试 pre-vote 请求。
扩展性
集群的成员发生变化时,存在某一时刻新老配置共存,进而有选举出两个领导者的可能

新集群节点在配置变更期间必须获得老配置的多数派投票才能成为 leader
发送新配置 c-new 给集群的领导者
领导者将自己的 c-old 配置与 c-new 合并为一个 c-old-new 配置【123-45】
然后下发给其他所有跟随者
- 当 c-old-new 被同步给半数以上节点后,那么此配置已经提交,遵循 raft 安全性机制
- 当 leader 在将 c-old-new 写入半数以上跟随者之前崩溃了,那么选举出来的新 leader 会退回到老的配置,此时重试更新配置即可
当 c-old-new 被提交之后,leader 会真正的提交 c-new 配置
- 如果提交给了半数节点,则 c-new 真正地被提交
- 如果未提交给半数节点时崩溃,则新选举的 leader 必定包含已提交的 c-old-new 那么接着更新配置即可 集群变更过于复杂,因此可以简化这一过程,使用单节点变更机制,即每一次只添加或删除一个节点

- 单节点变更时,如果 leader 挂了造成一致性问题(丢失已提交日志)如何处理?
新 leader 先发一条 no-op 日志再开始配置变更 可参考:Raft成员变更的工程实践
- 单节点变更时偶数节点遇到网络分区,则没有办法选举 leader 了怎么办?
重新定义偶数节点情况下的 法定人数模型下的大多数情况(n/2 或 n/2-1) 可参考: TiDB 在 Raft 成员变更上踩的坑 后分布式时代: 多数派读写的’少数派’实现
- 新的服务器没有存储任何日志,领导要复制很长一段时间,此时不能参加选举否则会使得整体不可用
新加入的节点设置一个保护期,再次保护期内不会参加选举与日志提交决策,只用来同步日志
- 如果集群的领导不是新集群中的一员,该如何处理?
在提交 c-new 时,不将自己算作半数提交,并且在提交后要主动退位
- 被移除的节点如果不及时关闭,会导致选举超时后强行发起投票请求干扰在线集群
每个节点如果未达到最小心跳时间,则不会进行投票
性能提升
- 生成快照 1)日志如果无线增长会将本地磁盘打满,会造成可用性问题
定期地将状态机中的状态生成快照,而将之前的日志全部删除,是一种常见的压缩方式
- 将节点的状态保存为 LSM Tree,然后存储最后应用日志的索引与任期,以保证日志匹配特性
- 为支持集群的配置更新,快照中也要将最后应用的集群配置也当做状态保存下来
- 当跟随者需要的日志已经在领导者上面被删除时(netxtIndex--),需要将快照通过RPC发送过去
注意:由领导人调用以将快照的分块发送给跟随者。领导者总是按顺序发送分块。
| 参数 | 解释 |
|---|---|
| term | 领导人的任期号 |
| leaderId | 领导人的Id,以便于跟随者重定向请求 |
| lastIncludedIndex | 快照中包含的最后日志条目的索引值 |
| lastincludedTerm | 快照中包含的最后日志条目的任期号 |
| offset | 分块在快照中的字节偏移量 |
| data[] | 从偏移量开始的快照分块的原始字节 |
| done | 如果这时最后一个分块则为 true |
| 结果 | 解释 |
|---|---|
| term | 当前任期号(currentTerm),便于领导人更新自己 |
2)快照何时创建?过于频繁会浪费性能,过于低频日志占用磁盘的量更大,重建时间更长。
限定日志文件大小到达某一个阈值后立刻生成快照
3)写入快照花费的时间昂贵如何处理?如何保证不影响节点的正常工作?
使用写时复制技术,状态机的函数式顺序性天然支持
- 调节参数
- 心跳的随机时间,过快会增加网络负载,过慢则会导致告知领导者崩溃的时间更长
- 选举的随机事件,如果大部分跟随者同时变为候选人则会导致选票被瓜分
- 流批组合
首先可以做的就是 batch,很多情况下 batch 可以明显提升性能,譬如对于 RocksDB 的写入来说,通过不会每次写入一个值,而是会用一个 WriteBatch 缓存一批修改,然后再整个写入。对于 Raft 来说,Leader 可以一次收集多个 requests,然后一批发送给 Follower。当然需要有一个最大发送 size 来限制每次最多可以发送数据
如果只是用 batch,Leader 还是需要等待 Follower 返回才能继续后面的流程,这里还可以使用 Pipeline 来进行加速。大家知道,Leader 会维护一个 NextIndex 的变量来表示下一个给 Follower 发送的 log 位置,通常情况下只要 Leader 跟 Follower 建立起了连接,都会认为网络是稳定互通的。所以只要当 Leader 给 Follower 发送了一批 log 后,可以直接更新 NextIndex,并且立刻发送后面的 log,不需要等待 Follower 的返回。如果网络出现了错误,或者 Follower 返回一些错误,Leader 就需要重新调整 NextIndex,然后重新发送 log 了。
- 并行追加
对于上面提到的一次 request 简易 Raft 流程来说,Leader 可以先并行地将 log 发送给 Followers,然后再将 log append。这么做的原因主要是因为在 Raft 里面,如果一个 log 被大多数的节点 append,我们就可以认为这个 log 是被 committed 了,所以即使 Leader 再给 Follower 发送 log 之后,自己 append log 失败 panic 了,只要 N/2+1 个 Follower 能接收都这个 log 并成功 append,仍然认为这个 log 是被 commited 了,被 commited 的 log 后续就一定能被成功 apply。
这么做的原因主要是因为 append log 会涉及到落盘,有开销,所以完全可以在 Leader 落盘的同时让 Follower 也尽快地收到 log 并 append 这里还需要注意,虽然 Leader 能在 append log 之前给 Follower 发 log,但是 Follower 却不能在 append log 之前告诉 Leader 已经成功 append 这个 log。如果 Follower 提前告诉 Leader 说已经成功 append,但实际后面 append log 时失败了,Leader 仍然会认为这个 log 是被 committed 了,这样系统就有丢失数据的风险了。
- 异步应用
上面提到,当一个 log 被大部分节点 append 后,就可以认为这个 log 被 committed 了,被 committed 的 log 在什么时候被 apply 都不会再影响数据的一致性。所以当一个 log 被 committed 之后,可以用另一个线程去异步地 apply 这个 log。 所以整个 Raft 流程就可以变成:
- Leader 接收一个 client 发送的 request
- Leader 将对应的 log 发送给其他 follower 并本地 append
- Leader 继续接受其他 client 的 requests,持续进行步骤2
- Leader 发现 log 已经被 committed,在另外一个线程 apply
- Leader 异步 apply log 之后,返回结果给对应的 client 使用 asychronous apply 的好处在于现在可以完全地并行处理 append log 和 apply log,虽然对于一个 client 来说,它的一次 request 仍然要走完完整的 Raft 流程,但对于多个 clients 来说,整体的并发和吞吐量是提升了。
Reference
11.6 - Raft-02.raftexample源码分析
前言
本文源码分析代码参考 etcd v3.5.0 中 raft 代码:
https://github.com/etcd-io/etcd/tree/v3.5.0/contrib/raftexample
Etcd 将 raft 协议实现为一个 library,然后本身作为一个应用使用它。此外,etcd 还提供了一个叫 raftexample 的示例程序。
在 etcd 中,raft 作为底层的共识模块,运行在一个 goroutine 里,通过 channel 接受上层(etcdserver)的消息,并将处理结果通过另一个 channel 返回给上层应用,交互过程大致如下:

这种全异步地交互方式好处是提高了性能,坏处是难以测试,增加代码阅读难度。
基本概念
源码中定义的一些变量概念
Node: 对 etcd-raft 模块具体实现的一层封装,方便上层模块使用 etcd-raft 模块;
上层模块: etcd-raft 的调用者,上层模块通过Node提供的API与底层的 etcd-raft 模块进行交互;
Cluster: 表示一个集群,其中记录了该集群的基础信息;
Member: 组层Cluster的元素之一,其中封装了一个节点的基本信息;
Peer: 集群中某个节点对集群中另一个节点的称呼;
Entry记录: 节点之间的传递是通过 message 进行的,每条消息中可以携带多条 Entry 记录,每条Entry对应一条一个独立的操作
1 2 3 4 5 6 7 8 9 10 11 12 13type Entry struct { // Term:表示该Entry所在的任期。 Term uint64 `protobuf:"varint,2,opt,name=Term" json:"Term"` // Index:当前这个entry在整个raft日志中的位置索引,有了Term和Index之后,一个`log entry`就能被唯一标识。 Index uint64 `protobuf:"varint,3,opt,name=Index" json:"Index"` // 当前entry的类型 // 目前etcd支持两种类型:EntryNormal和EntryConfChange // EntryNormaln表示普通的数据操作 // EntryConfChange表示集群的变更操作 Type EntryType `protobuf:"varint,1,opt,name=Type,enum=raftpb.EntryType" json:"Type"` // 序列化后的具体操作数据 Data []byte `protobuf:"bytes,4,opt,name=Data" json:"Data,omitempty"` }Message: 是所有消息的抽象,包括各种消息所需要的字段,raft集群中各个节点之前的通讯都是通过这个message进行的。
Expand/Collapse Code Block
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26type Message struct { // 该字段定义了不同的消息类型,etcd-raft就是通过不同的消息类型来进行处理的,etcd中一共定义了19种类型 Type MessageType `protobuf:"varint,1,opt,name=type,enum=raftpb.MessageType" json:"type"` // 消息的目标节点 ID,在急群中每个节点都有一个唯一的id作为标识 To uint64 `protobuf:"varint,2,opt,name=to" json:"to"` // 发送消息的节点ID From uint64 `protobuf:"varint,3,opt,name=from" json:"from"` // 整个消息发出去时,所处的任期 Term uint64 `protobuf:"varint,4,opt,name=term" json:"term"` // 该消息携带的第一条Entry记录的的Term值 LogTerm uint64 `protobuf:"varint,5,opt,name=logTerm" json:"logTerm"` // 索引值,该索引值和消息的类型有关,不同的消息类型代表的含义不同 Index uint64 `protobuf:"varint,6,opt,name=index" json:"index"` // 需要存储的日志信息 Entries []Entry `protobuf:"bytes,7,rep,name=entries" json:"entries"` // 已经提交的日志的索引值,用来向别人同步日志的提交信息。 Commit uint64 `protobuf:"varint,8,opt,name=commit" json:"commit"` // 在传输快照时,该字段保存了快照数据 Snapshot Snapshot `protobuf:"bytes,9,opt,name=snapshot" json:"snapshot"` // 主要用于响应类型的消息,表示是否拒绝收到的消息。 Reject bool `protobuf:"varint,10,opt,name=reject" json:"reject"` // Follower 节点拒绝 eader 节点的消息之后,会在该字段记录 一个Entry索引值供Leader节点。 RejectHint uint64 `protobuf:"varint,11,opt,name=rejectHint" json:"rejectHint"` // 携带的一些上下文的信息 Context []byte `protobuf:"bytes,12,opt,name=context" json:"context,omitempty"` }raftLog: Raft中日志同步的核心就是集群中leader如何同步日志到各个follower。日志的管理是在raftLog结构上完成的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16type raftLog struct { // 用于保存自从最后一次snapshot之后提交的数据 storage Storage // 用于保存还没有持久化的数据和快照,这些数据最终都会保存到storage中 unstable unstable // 当天提交的日志数据索引 committed uint64 // committed保存是写入持久化存储中的最高index,而applied保存的是传入状态机中的最高index // 即一条日志首先要提交成功(即committed),才能被applied到状态机中 // 因此以下不等式一直成立:applied <= committed applied uint64 logger Logger // 调用 nextEnts 时,返回的日志项集合的最大的大小 // nextEnts 函数返回应用程序已经可以应用到状态机的日志项集合 maxNextEntsSize uint64 }
raftexample
简介
raftexample 是一个 etcd raft library 的使用示例。它为Raft一致性算法的键值对集群存储提供了一个简单的 REST API。
总体架构图

启动
该包提供了goreman启动集群的方式,按照 README 使用 goreman start 启动,可以很清楚的看到 raft 在启动过程中的选举过程,能够很好的帮助理解 raft 的选举过程。
程序入口:
| |
下面介绍主要的函数
newRaftNode
在该函数中主要完成了 raftNode 的初始化 。在该方法中会使用上层模块传入的配置信息(其中包括 proposeC 通道和 confChangeC 通道)来创建raftNode实例,同时会创建 commitC 通道和 errorC 通道返回给上层模块使用 。这样上层模块就可以通过这几个通道与 rafeNode 实例进行交互。另外,newRaftNode() 函数中还会启动一个独立的后台 goroutine 来完成回放 WAL 日志、 启动网络组件等初始化操作。
Expand/Collapse Code Block
| |
startRaft
- 创建 Snapshotter,并将该 Snapshotter 实例返回给上层模块;
- 创建 WAL 实例,然后加载快照并回放 WAL 日志;
- 创建 raft.Config 实例,其中包含了启动 etcd-raft 模块的所有配置;
- 初始化底层 etcd-raft 模块,得到 node 实例;
- 创建 Transport 实例,该实例负责集群中各个节点之间的网络通信,其具体实现在 raft-http 包中;
- 建立与集群中其他节点的网络连接;
- 启动网络组件,其中会监听当前节点与集群中其他节点之间的网络连接,并进行节点之间的消息读写;
- 启动两个后台的 goroutine,它们主要工作是处理上层模块与底层 etcd-raft 模块的交互,但处理的具体内容不同,后面会详细介绍这两个 goroutine 的处理流程。
Expand/Collapse Code Block
| |
serveChannels
用于处理上次应用于底层 etcd-raft 模块的交互
Expand/Collapse Code Block
| |
领导者选举
启动节点
Node 初始化时是 Follower 状态,集群的节点初次启动时会通过 StartNode() 启动创建对应的 node 实例和底层的 raft 实例。StartNode() 方法根据传入的 config 配置创建 raft 实例并初始raft 使用的相关组件。
代码入口:
| |
StartNode 启动节点Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// raft/node.go:218
// Peer封装了节点 ID, 且记录了当前集群中全部节点 ID
func StartNode(c *Config, peers []Peer) Node {
if len(peers) == 0 {
panic("no peers given; use RestartNode instead")
}
// 根据 config 初始化 RawNode,并且也初始化一个 raft
rn, err := NewRawNode(c)
if err != nil {
panic(err)
}
// 第一次使用初始化 RawNode
err = rn.Bootstrap(peers)
if err != nil {
c.Logger.Warningf("error occurred during starting a new node: %v", err)
}
// 初始化 node 实例
n := newNode(rn)
go n.run()
return &n
}
func NewRawNode(config *Config) (*RawNode, error) {
// 调用初始化 newRaft
r := newRaft(config)
rn := &RawNode{
raft: r,
}
rn.prevSoftSt = r.softState()
rn.prevHardSt = r.hardState()
return rn, nil
}
func newRaft(c *Config) *raft {
if err := c.validate(); err != nil {
panic(err.Error())
}
raftlog := newLogWithSize(c.Storage, c.Logger, c.MaxCommittedSizePerReady)
hs, cs, err := c.Storage.InitialState()
if err != nil {
panic(err) // TODO(bdarnell)
}
r := &raft{
id: c.ID,
lead: None,
isLearner: false,
raftLog: raftlog,
maxMsgSize: c.MaxSizePerMsg,
maxUncommittedSize: c.MaxUncommittedEntriesSize,
prs: tracker.MakeProgressTracker(c.MaxInflightMsgs),
electionTimeout: c.ElectionTick,
heartbeatTimeout: c.HeartbeatTick,
logger: c.Logger,
checkQuorum: c.CheckQuorum,
preVote: c.PreVote,
readOnly: newReadOnly(c.ReadOnlyOption),
disableProposalForwarding: c.DisableProposalForwarding,
}
...
// 启动时都是 Follower 状态
r.becomeFollower(r.Term, None)
var nodesStrs []string
for _, n := range r.prs.VoterNodes() {
nodesStrs = append(nodesStrs, fmt.Sprintf("%x", n))
}
r.logger.Infof("newRaft %x [peers: [%s], term: %d, commit: %d, applied: %d, lastindex: %d, lastterm: %d]",
r.id, strings.Join(nodesStrs, ","), r.Term, r.raftLog.committed, r.raftLog.applied, r.raftLog.lastIndex(), r.raftLog.lastTerm())
return r
}
node 节点初始化时,所有 node 开始都被初始化为 Follower 状态。 run方法调用:
| |
run方法:Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func (n *node) run() {
...
for {
...
select {
case pm := <-propc:
...
err := r.Step(m)
...
case m := <-n.recvc:
// filter out response message from unknown From.
if pr := r.prs.Progress[m.From]; pr != nil || !IsResponseMsg(m.Type) {
r.Step(m)
}
case cc := <-n.confc:
...
case <-n.tickc:
n.rn.Tick()
case readyc <- rd:
n.rn.acceptReady(rd)
advancec = n.advancec
case <-advancec:
n.rn.Advance(rd)
rd = Ready{}
advancec = nil
case c := <-n.status:
c <- getStatus(r)
case <-n.stop:
close(n.done)
return
}
}
}
这里主要是通过 for - select - channel 监听 channel 信息来处理不同请求。 其中 propc 和 recvc 拿到上层应用传来的消息会提交给 raft 层的 Step 函数处理。
Step 函数是 etcd-raft 模块负责各类信息的入口。
| |
Step 函数中最后的 default 里的 step 被实现为一个状态机,其 step 属性是一个函数指针,根据当前节点的不同角色指向不同的消息处理函数:stepLeader/stepFollower/stepCandidate。同样还有一个 tick 函数指针,根据角色不同也有有不同的处理函数 tickHeartbeat 和 tickElection,分别用来触发定时心跳和选举检测。
发送心跳包
Leader
当一个节点成为 Leader 时,会将节点的定时器设置为 tickHearbeat,然后周期性调用以维持 Leader 地位。
Expand/Collapse Code Block
| |
becomeLeader 中 step 被设置成 stepLeader,所以会调用 stepLeader 来处理 leader 中对应的消息。然后调用 bcastHeartbeat 向所有节点发送心跳。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
func stepLeader(r *raft, m pb.Message) error {
// These message types do not require any progress for m.From.
switch m.Type {
case pb.MsgBeat:
// 向所有节点发送心跳
r.bcastHeartbeat()
return nil
case pb.MsgCheckQuorum:
// 检测是否和大部分节点保持连通
// 如果不连通则切换到 Follower 状态
if !r.prs.QuorumActive() {
r.logger.Warningf("%x stepped down to follower since quorum is not active", r.id)
r.becomeFollower(r.Term, None)
}
return nil
...
}
}
// bcastHeartbeat sends RPC, without entries to all the peers.
func (r *raft) bcastHeartbeat() {
lastCtx := r.readOnly.lastPendingRequestCtx()
// 下面函数都会调用 sendHeartbeat
if len(lastCtx) == 0 {
r.bcastHeartbeatWithCtx(nil)
} else {
r.bcastHeartbeatWithCtx([]byte(lastCtx))
}
}
// 向指定的节点发送消息
// sendHeartbeat sends a heartbeat RPC to the given peer.
func (r *raft) sendHeartbeat(to uint64, ctx []byte) {
// Attach the commit as min(to.matched, r.committed).
// When the leader sends out heartbeat message,
// the receiver(follower) might not be matched with the leader
// or it might not have all the committed entries.
// The leader MUST NOT forward the follower's commit to
// an unmatched index.
commit := min(r.prs.Progress[to].Match, r.raftLog.committed)
m := pb.Message{
To: to,
// 发送 MsgHeartbeat 类型的数据
Type: pb.MsgHeartbeat,
Commit: commit,
Context: ctx,
}
r.send(m)
}
最终的心跳通过 MsgHeartbeat 的消息类型进行发送,通知它们目前 Leader 的存活状态,重置所有 Follower 持有的超时计时器。
Follower
Follower 节点行为:
- 接收来自 Leader 的 RPC 消息 MsgHearbeat;
- 重置当前节点的选举超时时间;
- 回复 Leader 自己存活。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16nc stepFollower(r *raft, m pb.Message) error { switch m.Type { case pb.MsgProp: ... case pb.MsgHeartbeat: r.electionElapsed = 0 r.lead = m.From r.handleHeartbeat(m) ... } return nil } func (r *raft) handleHeartbeat(m pb.Message) { r.raftLog.commitTo(m.Commit) r.send(pb.Message{To: m.From, Type: pb.MsgHeartbeatResp, Context: m.Context}) }
Candidate
Candidate 来处理 MsgHeartbeat 的信息,是先把自己变成 Follower,然后和上面的 Follower 一样,回复 Leader 自己存活。
| |
当 Leader 收到返回信息时,会将对应节点设置为 RecentActive,表示该节点目前存活。
| |
如果 Follower 在一定的时间内,没有收到 Leader 节点的消息,就会发起新一轮的选举,重新选一个 Leader 节点。
Leader 选举
接收心跳
Expand/Collapse Code Block
| |
小结:
- 如果可以成为 Leader
- 当没有收到 Leader 心跳且候选超时时间过期;
- 则重新发起新的选举请求;
发起竞选
Step 函数接收 MsgHup 这个类型的消息后会调用 campaign 函数,进入竞选状态。
Expand/Collapse Code Block
| |
切换到 campaign 状态,然后发送自己的 term 消息,请求投票。 campaignPreElection 的作用:
当系统之前出现分区,在分区消失后恢复时,可能会造成某个被 split 的 Followe r的 Term 数值很大。 对服务器进行分区时,它将不会收到 heartbeat 包,每次 electionTimeout 后成为 Candidate 都会递增 Term。 服务器在一段时间后恢复连接时,Term 的值将会变得很大,然后引入的重新选举会导致导致临时的延迟与可用性问题。 PreElection 阶段并不会真正增加当前节点的 Term,它的主要作用是得到当前集群能否成功选举出一个 Leader 的答案,避免上面这种情况的发生。
接受消息并投票
投票需要满足的条件:
- 当前节点没有给任何节点投票或者 投票的节点 term 大于本节点的或者 是之前已经投票的节点;
- 该节点的消息是最新的。
Expand/Collapse Code Block
| |
Candidate 统计投票
Candidate 节点接收到投票信息,然后统计投票数量
- 如果投票数大于节点数的一半,成为 Leader;
- 如果不满足则变成 Follower;
Expand/Collapse Code Block
| |
每当收到一个 MsgVoteResp 类型的消息时,就会设置当前节点持有的 votes 数组,更新其中存储的节点投票状态,如果收到大多数的节点票数,切换成 Leader,向其他的节点发送当前节点当选的消息,通知其余节点更新 Raft 结构体中的 Term 等信息。 状态切换:

PreCandidate 是为了防止在分区的情况下,某个 split 的 Follower 的 Term 数值变得很大。
不同节点之间的切换,调用对应的 bacome* 方法就可以。需要注意每个 bacome* 中的 step 和 tick。
Expand/Collapse Code Block
| |
step 属性是一个函数指针,根据当前节点的不同角色,指向不同的消息处理函数,如 stepLeader/stepFollower/stepCandidate。 tick 也是一个函数指针,根据角色的不同,也会在 tickHeartbeat 和 tickElection 之间来回切换,分别用来触发定时心跳和选举检测。
日志同步
WAL 日志
WAL(Write Ahead Log)主要的作用是记录整个数据变化的全部历程。etcd 中所有数据的修改在提交前,都要先写入到 WAL 中。这使得etcd拥有两个重要功能:
- 故障快速恢复
数据遭到破坏时,可以通过执行所有 WAL 中记录的修改操作,快速从最原始的数据恢复到数据损坏前的状态。
- 数据回滚(undo)/重做(redo)
所有的修改操作都被记录在 WAL 中,需要回滚或重做,只需要反向或正向执行日志中的操作即可,类似 MySQL 。
etcd中处理 Entry 记录的流程图(摘自【etcd技术内幕】)

具体流程如下:
- 客户端向 etcd 集群发起一次请求,请求中封装的 Entry 首先会交给 etcd-raft 处理,etcd-raft 会将 Entry 记录保存到 raftLog.unstable 中;
- etcd-raft 将 Entry 记录封装到 Ready 实例中,返回给上层模块进行持久化;
- 上层模块收到持久化的 Ready 记录后,会记录到 WAL 文件中然后持久化,最后通知 etcd-raft 模块进行处理;
- etcd-raft 将该 Entry 记录从 unstable 中移到 storage 中保存;
- 当 Entry 记录被复制到集群中的半数以上节点时,该记录会被 Leader 节点认为已经提交,封装到 Ready 实例中通知上层模块;
- 此时上层模块将该 Ready 实例封装的 Entry 记录应用到状态机上。
同步日志
etcd 中 Leader 节点数据 同步 到 Follower 流程图:

etcd 日志保存的总体流程如下:
1、集群某个节点收到 client 的 put 请求要求修改数据。节点会生成一个 Type 为 MsgProp 的Message,发送给 Leader。
Expand/Collapse Code Block
| |
2、Leader 收到 Message 后,会处理 Message 中的日志条目,将其 append 到 raftLog 的unstable 的日志中,并且调用 bcastAppend() 广播 append 日志的消息。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func stepLeader(r *raft, m pb.Message) error {
// These message types do not require any progress for m.From.
switch m.Type {
...
case pb.MsgProp:
...
// 将Entry记录追加到当前节点的raftlog中
if !r.appendEntry(m.Entries...) {
return ErrProposalDropped
}
// 向其他节点复制Entry记录
r.bcastAppend()
return nil
...
}
return nil
}
func (r *raft) maybeSendAppend(to uint64, sendIfEmpty bool) bool {
pr := r.prs.Progress[to]
if pr.IsPaused() {
return false
}
m := pb.Message{}
m.To = to
...
m.Type = pb.MsgApp
m.Index = pr.Next - 1
m.LogTerm = term
m.Entries = ents
m.Commit = r.raftLog.committed
if n := len(m.Entries); n != 0 {
switch pr.State {
// optimistically increase the next when in StateReplicate
case tracker.StateReplicate:
last := m.Entries[n-1].Index
pr.OptimisticUpdate(last)
pr.Inflights.Add(last)
case tracker.StateProbe:
pr.ProbeSent = true
default:
r.logger.Panicf("%x is sending append in unhandled state %s", r.id, pr.State)
}
}
r.send(m)
return true
}
3、Leader 中的消息最终会以 MsgApp 类型的消息通知 Follower,Follower 收到信息之后,同 Leader 一样先将缓存中的日志条目持久化到磁盘中,并将当前已经持久化的最新日志 index 返回给 Leader。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func stepFollower(r *raft, m pb.Message) error {
switch m.Type {
case pb.MsgApp:
r.electionElapsed = 0
r.lead = m.From
r.handleAppendEntries(m)
}
return nil
}
func (r *raft) handleAppendEntries(m pb.Message) {
....
if mlastIndex, ok := r.raftLog.maybeAppend(m.Index, m.LogTerm, m.Commit, m.Entries...); ok {
r.send(pb.Message{To: m.From, Type: pb.MsgAppResp, Index: mlastIndex})
}
...
}
// maybeAppend returns (0, false) if the entries cannot be appended. Otherwise,
// it returns (last index of new entries, true).
func (l *raftLog) maybeAppend(index, logTerm, committed uint64, ents ...pb.Entry) (lastnewi uint64, ok bool) {
...
l.commitTo(min(committed, lastnewi))
...
return 0, false
}
func (l *raftLog) commitTo(tocommit uint64) {
// never decrease commit
if l.committed < tocommit {
if l.lastIndex() < tocommit {
l.logger.Panicf("tocommit(%d) is out of range [lastIndex(%d)]. Was the raft log corrupted, truncated, or lost?", tocommit, l.lastIndex())
}
l.committed = tocommit
}
}
4、最后leader收到大多数的follower的确认,commit自己的log,同时再次广播通知follower自己已经提交了。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func stepLeader(r *raft, m pb.Message) error {
// These message types do not require any progress for m.From.
switch m.Type {
...
case pb.MsgAppResp:
pr.RecentActive = true
if r.maybeCommit() {
releasePendingReadIndexMessages(r)
// 如果可以commit日志,那么广播append消息
r.bcastAppend()
} else if oldPaused {
// 如果该节点之前状态是暂停,继续发送append消息给它
r.sendAppend(m.From)
}
...
}
return nil
}
// 尝试提交索引,如果已经提交返回true
// 然后应该调用bcastAppend通知所有的follower
func (r *raft) maybeCommit() bool {
mci := r.prs.Committed()
return r.raftLog.maybeCommit(mci, r.Term)
}
// 提交修改committed就可以了
func (l *raftLog) commitTo(tocommit uint64) {
// never decrease commit
if l.committed < tocommit {
if l.lastIndex() < tocommit {
l.logger.Panicf("tocommit(%d) is out of range [lastIndex(%d)]. Was the raft log corrupted, truncated, or lost?", tocommit, l.lastIndex())
}
l.committed = tocommit
}
}
实战
利用 raft 算法库实现一个分布式存储,此算法库已被广泛应用在 etcd、cockroachdb、dgraph 等开源项目中。
总体架构:

API 设计
API 由一组接口定义和协议组成,设计 API 会考虑如下因素:
性能 如 etcd v2 使用的是 HTTP/1.x,性能上无法满足大规模 Kubernetes 集群等场景的诉求,etcd v3 使用的是基于 HTTP/2 的 gRPC 协议
易用性、可调试性 有的内部高并发服务为了性能会使用 UDP 协议,相比 HTTP 协议,UDP 协议在易用性、可调试性存在一定差距
开发效率、跨平台、可移植性 相比基于裸 UDP、TCP 协议设计的接口,如果使用 Protobuf 等 IDL 语言,支持跨平台、代码自动生成,开发效率更高。
安全性 使用 HTTPS 协议可对通信数据加密更安全,可适用于不安全的网络环境(比如公网传输)
接口幂等性 基于 raftexample 定制开发,因此日志持久化存储、网络都使用的是 etcd 自带的 WAL 和 rafthttp 模块。
WAL模块中提供了核心的保存未持久化的日志条目和快照功能接口;rafthttp模块基于 HTTP 协议提供了各个节点间的消息发送能力
复制状态机
复制状态机由共识模块、日志模块、状态机组成。

复制状态机的写请求流程:
- client 发起一个写请求;
- server 向 Raft 共识模块提交请求,共识模块生成一个写提案日志条目。若 server 是 Leader,则把日志条目广播给其他节点,并持久化日志条目到 WAL 中;
- 当一半以上节点持久化日志条目后,Leader 的共识模块将此日志条目标记为已提交(committed),并通知其他节点提交;
- server 从共识模块获取已经提交的日志条目,异步应用到状态机存储中(boltdb/leveldb/memory),然后返回给 client。
多存储引擎
raftexample 本身只支持内存存储。通过将 KV 存储接口进行抽象化设计,实现支持多存储引擎。KVStore interface 的定义如下所示。
Expand/Collapse Code Block
| |
存储引擎的选型及基本原理
boltdb
github 链接:https://github.com/etcd-io/bbolt
boltdb 是一个基于 B+ tree 实现的存储引擎库。
boltdb 适用于读多写少,一般情况下可直接从内存中基于 B+ tree 遍历,快速获取数据返回给 client,不涉及经过磁盘 I/O。
对于写请求,基于 B+ tree 查找写入位置,更新 key-value。事务提交时,写请求包括 B+ tree 重平衡、分裂、持久化 dirty page、持久化 freelist、持久化 meta page 流程。同时,dirty page 分布位置可能分散,这里是随机写磁盘 I/O。
leveldb
github 链接:https://github.com/google/leveldb 和 https://github.com/syndtr/goleveldb
写多读少最简单的自然就是通过写内存最快,但是内存空间有限,无法支撑大容量的数据存储,且不持久化数据会丢失。
可以通过将数据顺序追加到文件末尾(AOF)来提升写速度。Bitcask存储模型就是采用 AOF 模式,把写请求顺序追加到文件。Facebook 的图片存储Haystack也是使用类似的方案来解决大规模写入痛点。
Bitcask存储模型通过内存哈希表维护各个 key-value 数据的索引,实现了快速查找 key-value 数据。虽然内存只保存 key 索引信息,但是当 key 较多时对内存要求依然较高。
leveldb 是基于 LSM tree(log-structured merge-tree) 实现的 key-value 存储(架构可参考:https://microsoft.github.io/MLOS/notebooks/LevelDbTuning/),提升写性能的核心思路同样是将随机写转化为顺序写磁盘 WAL 文件和内存。
读写流程
写流程:
- client 通过 curl 发送 HTTP PUT 请求到 server;
- server 收到后,将消息写入到 KVStore 的 ProposeC 管道;
- raftNode 循环逻辑将消息通过 Raft 模块的 Propose 接口提交;
- Raft 模块输出 Ready 结构,server 将日志条目持久化后,并发送给其他节点;
- 集群多数节点持久化此日志条目后,这个日志条目被提交给存储状态机 KVStore 执行;
- KVStore 根据启动的 backend 存储引擎名称,调用对应的 Put 接口即可。

读流程:
- client 通过 curl 发送 HTTP Get 请求到 server;
- server 收到后,根据 KVStore 的存储引擎,从后端查询出对应的 key-value 数据。

总结
- etcd 中的 raft 是作为一个 library 被使用的。这个库仅仅实现了对应的 raft 算法;
- etcd-raft 这种实现的过程,其中的 step 和 tick 被设计成了函数指针,根据不同的角色来防止不同的函数;
- 为了防止出现网络分区 Term 数值变得很大的场景,引入了 PreCandidate;
- etcd 中所有的数据都是通过 Leader 分发到 Follower,通过日志的复制确认机制,保证绝大多数的 Follower 都能同步到消息。
Reference
《etcd技术内幕》
11.7 - 分布式锁实现及方案比较
背景
单机中可以利用语言自身提供的能力或者借助第三方包来实现对资源的安全并发访问。随着微服务的流行,各种服务都慢慢演变成了分布式架构,部署在不同机器上。这样会使得一些资源在共享上也需要保证安全性问题,需要用到特殊的锁——分布式锁。
实现方案
数据库方案
数据表简单分布式锁
基于数据库来实现分布式锁需要依赖一张数据表,表结构如下:
| |
对方法“method_A”添加锁时,可以采用下面的SQL语句往上表中插入一条记录
| |
因为数据表在method_name字段上创建了唯一索引,如果同时有多个插入请求提交,数据库会保证只会有一条记录插入成功。这样就可以认为插入记录成功的这个线程获取到了分布式锁,它可以执行后续的方法逻辑。 当方法执行完成后,需要执行下面的SQL语句来释放锁
| |
上面利用数据库表简单实现了分布式锁的获取和释放,但是其存在多方面的问题:
对数据库是强依赖,存在单点问题,一旦数据库挂起,业务系统将不可用
没有失效时间,一旦释放锁操作失败,将会一直持有锁,导致其他线程无法获取到锁
非阻塞式,因为插入操作一旦失败数据库会直接返回错误信息,线程未获取到锁也不会进入等待重试队列,要再次获取锁,需要再次触发插入操作
非可重入锁,同一线程在未释放锁前无法再次获取该锁,因为数据表中的数据已经存在了
非公平锁,所有等待锁的线程会同时争抢锁,最终谁能够获取到锁,就要看谁比较幸运数据能够插入成功 当然,上述几个问题也有相应的解决方案
数据库单点?可以建立数据库集群,在集群间进行数据同步,一旦主库挂掉,可以快速切到备库,保证分布式锁服务可用
没有失效时间?只需要额外建立一个定时任务,定时清理超时数据
非阻塞?可以通过while循环,在代码中添加重试逻辑,直到获取锁成功
不可重入?在数据库中添加一个字段,记录当前获取到锁的主机和线程信息,下一次在获取锁前,先查询这个字段的信息,如果与当前获取锁的线程信息一致,则可以直接为该线程返回锁
不公平?我们可以创建一张中间表来记录所有获取锁的线程,这张表就充当了队列的作用,按照插入时间排序,只有排在第一位的线程有机会获取锁
数据库排他锁分布式锁
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式锁。
仍然借助上面创建的数据表,在MySQL数据库的InnoDB引擎模式下,可以通过下面的方法来实现分布式锁操作,伪代码如下:
| |
在查询语句后面添加“for update”语句,数据库会在查询过程中向数据库表添加独占锁,其他线程就无法再对这行记录加锁了。 我们可以假定一个线程获取了独占锁就获取了分布式锁,获取到锁以后,就可以执行后续业务逻辑。使用这种方式要注意进行事务的手动管理,在事务提交前执行业务逻辑,执行完成后手动提交事务来释放锁。
| |
通过上面的这种方式可以高效解决上面提到的解锁和阻塞锁的问题。 针对解锁问题,在获取锁以后,如果服务突然宕机,不用担心锁不会释放,数据库连接会在在服务宕机后自动断开并释放锁。
针对阻塞问题,采用for update语句,其会保持阻塞状态,在成功执行或者执行失败后立即返回。
上述方案虽然解决了阻塞和解锁问题,但是数据库的单点、可重入、公平锁等问题仍需要进行额外处理。
小结
基于数据库实现分布式锁都依赖于一个数据库表。一种是通过表中记录的存在来确定当前是否存在锁,另一种是通过数据库的排他锁来实现分布式锁。
优点就是简单明了,容易理解;缺点也很明显,为了实现一个功能完善的分布式锁,需要一堆额外的优化措施,使得整个方案变得愈加复杂,同时操作数据库需要一定的开销,性能问题不容忽视。
redis方案
setnx对应api有诸如setIfAbsent(key)
可能存在死锁,如果处理的线程抛出异常无法释放锁
setnx expire增加超时时间
redis实现分布式锁主要使用setnx这个命令实现,该命令的语义为如果该键不存在就设置该键的值,保证只有一个客户端能设置成功。
实现1
加锁:
| |
解锁:
| |
问题:setnx执行成功,而expire失败后机器宕机,会导致锁永远无法被释放。该方案基本不会被使用。
实现2
redis从2.6.12版本开始,SET命令开始支持超时参数,并且保证整个操作是原子的。
加锁:
| |
解锁:
| |
问题: 1、持有锁的线程挂掉后,其他线程必须等待锁超时失效后才能重新争抢;
2、不能保证删除锁的线程是锁的拥有者。
实现3
加锁:
| |
解锁:
| |
问题:不能保证删除锁的线程是锁的拥有者。
实现4(防盗抢)
加锁:
| |
value设置为一个独特的token。 解锁:
| |
小结
redis实现的原理是通过一致性哈希将不同key的散列到不同的机器进行处理,最终也是由master处理写。
redis实现的分布式锁性能最优,缺点是超时时间的设定比较困难。
如果需要保证只有锁的持有者才能释放锁,建议使用方案实现4,否则使用方案实现3,实现3可以减少锁持有者宕机后其他线程的等待时间。
redisson方案
背景
redis实现分布式锁存在的问题, 为了解决redis单点问题, 我们会部署redis集群, 在Sentinel集群中, 主节点突然挂掉了。同时主节点中有把锁还没有来得及同步到从节点。这样就会导致系统中同样一把锁被两个客户端同时持有, 不安全性由此产生。redis官方为了解决这个问题, 推出了Redlock算法解决这个问题。但是带来的网络消耗较大。
简介
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
实现
分布式锁的redisson实现:
| |
获取锁释放锁
| |
集群模式
| |
主从模式
| |
使用:
Expand/Collapse Code Block
| |
zookeeper方案
基础知识
基于ZooKeeper,是使用它的临时有序节点来实现的分布式锁。
节点类型:
1、持久节点:创建后一直存在,直到主动删除;
2、临时节点:临时节点的生命周期和客户端会话绑定,客户端节点失效后,这个节点会自动被清除;
3、持久顺序节点:基本特性和持久节点一致,只是在创建节点时路径名后面增加一个自增的数字后缀;数字后缀的值记录在父节点的一个4bytes的字段中。
4、临时顺序节点:基本特性同临时节点,同样节点路径名带有自增数字。
实现原理
加锁流程:
1、创建永久节点如 resourcex 代表争抢的资源;
2、所有想获取锁的客户端都到 resourcex 目录下创建临时顺序节点;
3、客户端获取当前目录下所有子节点,判断自己的节点是否位于子节点第一个;
4、如果是第一个,则获取到锁,返回成功;
5、如果不是第一个,则说明前面已经有客户端获取到锁了,那么需要获取自己节点的前一个节点,并监听前一个节点的状态,当监听到前一个节点被删除后返回步骤3。
解锁的流程:
删除当前客户端注册的临时节点即可。
锁超时:
Zookeeper不需要配置锁超时,创建临时节点的客户端维护着一个和服务端的session,服务端通过session判断客户端是否在线,当客户端机器宕机时,服务端会删除对应的临时节点。
原生使用
Expand/Collapse Code Block
| |
小结
zk实现的原理主要是ZAB协议控制所有写请求都转发到leader节点执行,以保证新建节点的唯一性。zk实现的分布式锁可用性较高,实现简单,自动锁释放,缺点是创建和删除节点的性能较差。zk分布式锁对于锁的qps和锁数量都有一定的要求,在锁QPS超过100或者锁的数量超过50万,建议不要使用基于ZK的分布式锁,应寻求其他分布式锁方案。
方案比较
| mysql | redis | redisson | zookeeper | |
|---|---|---|---|---|
| 实现复杂度 | 复杂 | 简单 | 简单 | 简单 |
| 性能 | 3 | 1 | 1 | 2 |
| 可用性 | 3 | 2 | 2 | 1 |
| 可重入性 | 需要额外增加字段支持 | 支持 | 支持 | 支持 |
| 锁超时 | 需要额外增加字段支持 | 支持,超时后失效 | 支持,超时后失效 | 支持,宕机后节点自动失效 |
总结
- mysql方案看起来最简单,但实现过程中的细节较多,性能一般,适用于特别简单或者没有其他方案可选的场景,一般不建议使用该方法。
- redis 性能是最高的,使用场景最多,但可能存在单点问题,要么业务上自己处理,要么使用官方推荐的 RedLock,实际生产中需要注意 redis 多机房带来的锁问题。
- zookeeper 的性能不如 redis,当 qps 高于 100 或锁数量大于 50 万时建议使用redis(性能数据来源于参考文档)。
Reference
11.8 - 常见分布式拓扑结构
背景
分布式架构一般有以下几种结构:
- 主从架构
- 去中心化架构
- 混合型架构
Redis分布式模式
Redis一般有两种模式:
- 主从模式
- 集群模式(Redis Cluster)
主从模式
标准主从模式
标准的Redis主从模式,只有 Master 节点接收写入请求,并将写入的数据复制给一个或多个Slave,形成良好的读写分离机制,多个 Slave 可以分担读请求。Redis 主从是标准的分布式中心化思想。

树形主从模式
Redis 应用场景大多是极高并发的内存 I/O,常见应用场景是作为数据库的防护网,防止数据库被击穿,因此标准的主从模式中的 Master 对外既要承担写入,对内又要承担各个节点的复制操作,资源消耗很大,且随着 slave 节点增多问题越发明显。因此又形成主从的一个变形形式。

集群模式
对于高并发的业务场景,Master 始终是一个隐患。因为 Master 承担着所有的写操作,如果没有HA解决方案一旦宕机,集群将不可用。因此 Redis 社区推出集群方案,主要是为了解决 Master 压力,即集群的分布式无中心模式。

无中心模式采用虚拟槽概念,独立于物理节点。虚拟槽有 0~16383 个,数据会进行 key 的hash计算,确定进入哪个槽位。而物理节点负责哪些虚拟槽,即对应关系可以自行指定。
每个节点(数据节点)都是对等节点。此外,为了高可靠HA,每个节点也可以再演变成 Master 和 Slave 的主从模式部署。
小结
Redis 最成熟的方案还是主从模式,Redis Cluster 带来的性能优势无法抵消去中心化带来的不成熟和不可靠问题,导致人工运维的复杂度和难度。生产中慎用 Redis Cluster。
Kafka
架构

Kafka 集群使用 Zookeeper 集群来管理,Broker 的注册发现等都是依靠 zookeeper 来协助完成。每个 Broker 会以一主二从(Leader 和 Follower)的方式均匀分布。
生产者(Product)从任意节点获取 Meta 信息,找到 Broker 中的 Leader 分区副本,会向里面写分配好的数据,Leader 会向集群中其他 Broker 的 Follower 分区副本复制。
小结
Broker 分区信息是分布在每一台 Broker 的 meta 缓存里面,生产者和消费者可以在任意一台 Borker 上获取需要操作的 Leader 分区信息,Kafka 这种设计有点去中心化的意思。但是这些 meta 缓存信息实质是来自 Zookeeper(强依赖),所以本质上 Kafka 依然是中心化管理。
RocketMQ
架构

RocketMQ 总体结构与 Kafka 类似,更适合高并发场景,使用 NameServer 替代了 Zookeeper。
NameServer 是无中心节点的,都通过锁注册表的方式共享信息。NameServer 是 Broker 的注册表,Broker 新注册或者异常退出,对应的 NameSever 都会感知到。NameServer 增加/删除所辖 Broker 信息到该注册表,并定时读取最新的集群所有broker信息。
生产者(Producet)连接一个 NameServer,便能获取到想要发送分区数据的 Brokers(消费者同理)。
每个 Borker 可以再分成主从模式,Master 进行队列操作,Slave 只做数据同步,Master 出现故障时进行替换。
小结
NameSever 相对于 Zookeeper 的结构更简单,生产者和消费者对 Broker 以及分区的获取必须来自 NameSever,尽管 NameSever 集群本身是无中心的,但整个 RocketMQ 的 Brokers 是被 NameSever 中心化管理的,但整体上 Product、Consumer、Brokers集群对这种集中管理的依赖程度并不高,只是提供了很简单的 Broker 元信息服务,真正的数据流还是交给各个Broker 自行解决。
12 - RPC
Introduction
RPC
12.1 - RPC不同实现方式
RMI
简介
RMI(remotemethodinvocation) 是java原生支持的远程调用, RMI采用RMP(JavaRemoteMessageing Protocol) 作为通信协议, 可以认为是纯java版本的分布式远程调用解决方案。
RMI的核心概念

RMI使用步骤
1、创建远程接口, 并且继承java.rmi.Remote接口
2、实现远程接口, 并一继承; UnicastRemoteObject
3、创建服务器程序: createRegistry() 方法注册远程对象
4、创建客户端程序(获取注册信息, 调用接口方法}
1、创建服务接口
| |
2、实现远程接口 略
3、创建服务器程序
| |
4、客户端
| |
注意:客户端的包路径需要和服务端一致
hessian
简介
Hessian使用C/S方式,基于HTTP协议传输,使用Hessian二进制序列化。
使用
Service端
1、添加maven依赖
| |
2、创建接口UserService
| |
3、实现类
| |
4、web.xm中配置HessianServlet
| |
5、添加tomcat7插件启动服务
| |
6、在maven插件栏中找到tomcat,run启动
客户端
1、添加hessian的maven依赖(同上)
2、创建跟servlet端相同的端口UserService(同上)
3、创建测试类测试
| |
thirft
使用IDL
gRpc
Dubbo
自己实现RPC
基本实现思路:

- provider服务提供
- consumer服务消费
- registry注册
- protocol协议
服务提供者
1、定义服务接口
| |
2、实现类HelloServiceImpl
| |
3、服务注册:注册中心
此处注册中心我们将服务注册在map集合中, 结构: Map<String. Map
Expand/Collapse Code Block
| |
注意重写equals和hashCode方法,为了比较URL时不是比较地址值,而是比较其中的hostName和port值。否则将拿不到注册URL的信息
注册中心NativeRegistry
Expand/Collapse Code Block
| |
注册服务ServerStart
| |
4、暴露服务
服务之间调用的通信协议采用http协议,所以在服务provider中启动tomcat暴露服务
添加内嵌tomcat的依赖
| |
创建HttpServer
Expand/Collapse Code Block
| |
DispatcherServlet
| |
HttpServerHandler
Expand/Collapse Code Block
| |
Invocation
| |
客户端
HttpClient
Expand/Collapse Code Block
| |
Invocation和HelloService同服务端
ClientStart
| |
客户端改进版
客户端使用反射,模拟真实调用场景,将http请求代理一下
HttpClient
Expand/Collapse Code Block
| |
HttpHandler
Expand/Collapse Code Block
| |
ClientStart
| |
13 - MQ
Introduction
MQ
13.1 - 深入理解Kafka:核心设计与实践原理
01 初始Kafka
“扮演“的三大角色:
1、消息系统:解耦、冗余存储、流量削峰、缓冲、异步通信、扩展性、可恢复性等。此外,kafka还提供了消息顺序性保障及回溯消费的功能。
2、存储系统:kafka把消息持久化到磁盘,相比于其他基于内存存储的系统而言,有效地降低了数据丢失的风险。得益于kafka的消息持久化功能和多副本机制,可以把kafka作为长期的数据存储系统来使用,只需要把对应的数据保留策略设置为”永久“或启用主体的日志压缩功能即可。
3、流式处理平台
1.1 基本概念
一个典型的Kafka体系架构包括若干Producer、若干Broker、若干Consumer,以及一个ZooKeeper集群。
(1)Producer:生产者,发消息的一方。负责创建消息,然后投递到Kafka中。
(2)Consumer:消费者,也就是接收消息的一方。连接到Kafka上并接收消息,进而进行相应的业务逻辑处理。
(3)Broker:服务代理节点。可以简单看作成一个独立的Kafka服务节点或Kafka服务实例。
主题分区。kafka保证的是分区有序而不是主题有序。
Kafka的分区可以分布在不同的服务器(broker)上,一个主题可以横跨多个broker,以此来提供比单个broker更强大的性能。

Kafka为分区引入了多副本(Replica)机制,通过增加副本数量可以提升容灾能力。同一分区的不同副本中保存的是相同的消息(同一时刻,副本之间并非完全一样),副本之间是“一主多从”的关系,其中leader副本负责处理读写请求,follower副本只负责与leader副本消息同步。副本处于不同的broker中,当leader副本出现故障时,从follower副本中重新选举新的leader副本对外提供服务。
分区中的所有副本统称为 AR(Assigned Replicas)。所有与leader副本保持一定程度同步的副本(包括leader副本在内)组成ISR(In-Sync Replicas)。与leader副本同步滞后过多的副本(不包括leader副本)组成OSR(Out-of-Sync Replicas),AR=ISR+OSR。正常情况下,所有follower副本与leader副本保持一定程度的同步,即AR=ISR。
HW(High Watermark):高水位。消费者只能拉取这个offset之前的消息。
LEO(Log End Offset):标识当前日志文件中下一条待写入消息的offset。

同步消息时(从leader副本到follower副本),不同的follower的同步效率不尽相同,HW取所有LEO中的最小值。

1.2 安装与配置
1、JDK的安装与配置
2、ZooKeeper安装与配置
ZooKeeper实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、配置维护等功能。ZooKeeper中共有3中角色:leader、follower和observer。observer不参与投票,默认情况下ZooKeeper中只有leader和follower两种角色。
3、Kafka的安装与配置
1.3 生产与消费
shell和Java编程
1.4 服务端参数配置
1、zookeeper.connect 该参数指明broker要连接的ZooKeeper集群的服务地址(包含端口号),没有默认值,为必填项。如localhost:2181,如集群有多个节点,用多个逗号将每个节点隔开。(升华:复用ZooKeeper集群,增加chroot路径)
2、 listeners 该参数指明broker监听客户端连接的地址列表,即为客户端要连接的broker的入口地址列表,配置格式为protocol1://hostname1:port1, protocol2://hostname2:port2,其中protocol代表协议类型,Kafka目前支持PLAINTEXT、SSL、SASL_SSL等,如未开启安全认证,使用简单的PLAINTEXT即可。
3、 broker.id Kafka集群中broker的唯一标识,默认值为-1。
4、log.dir和log.dirs Kafka把所有的消息都保存在磁盘上,而这两个参数用来配置Kafka日志文件存放的根目录。前者配置单个根目录,后者配置多个根目录(以逗号隔开),但没有严格限制,两者都可配置单个/多个根目录。后者优先级比前者高。前者默认值:/tmp/kafka-logs。
5、message.max.bytes 该参数用来指定broker所能接收消息的最大值,默认值为1000012(B),约等于976.6KB。如果Producer发送的消息大于该值,会报出RecordTooLargeException的异常。修改需考虑max.request.size(客户端参数)、max.message.bytes(topic端参数)等的影响,建议另考虑分拆消息的可行性。
1.5 总结
02 生产者
2.1 客户端开发
正常的生产逻辑步骤:
1、配置生产者客户端参数及创建相应的生产者实例;
2、构建待发送的消息;
3、发送消息;
4、关闭生产者实例。
2.1.2 消息的发送
发送消息的三种模式:发后即忘(fire-and-forget)、同步(sync)及异步(async)。
2.1.3 序列化
2.1.4 分区器
send()方法发往broker的过程中,有可能需要经过拦截器(Interceptor)(非必须)、序列化器(Serializer)(必须)和分区器(Partitioner)的一系列作用之后才能被真正地发往broker。如果ProducerRecord中指定了partition字段,就不需要分区器,因为该字段代表了分区号。
2.1.5 生产者拦截器
生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规则过滤不符合要求的消息、修改消息的内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。
2.2 原理分析
2.2.1 整体架构
2.2.2 元数据的更新
2.3 重要的生产者参数
1、acks
2、max.request.size 生产者客户端能发送的消息的最大值,默认1MB
3、retries(重试次数)和retry.backoff.ms(重试时间间隔)
4、compression.type(消息的压缩方式)
5、connections.max.idle.ms(多久关闭限制的链接)
6、linger.ms
7、receive.buffer.bytes(Socket接收消息缓冲区SO_RECBUF的大小)
8、send.buffer.bytes(Socket发送消息缓冲区SO_RECBUF的大小)
9、request.timeout.ms(Producer等待请求响应的最长时间)
2.4 总结
KafkaProducer是线程安全的,可以在多线程的环境中复用,而对于下一章的消费者客户端KafkaConsumer而言,是非线程安全的,因为它具备了状态。
03 消费者
3.1 消费者与消费组
对于消息中间件而言,一般有两种消息投递模式:点对点(P2P,Point-to-Point)模式和发布/订阅(Pub/Sub)模式。点对点模式是基于队列的,消息生产者发送消息到队列,消息消费者从队列中接收消息。发布订阅模式定义了如何向一个内容节点发布和订阅消息,这个内容节点成为主体(Topic),主体可以认为是消息传递的中介,消息发布者将消息发布到某个主题,而消息订阅者从主题中订阅消息。主题使得消息的订阅者和发布者互相保持独立,不需要进行接触即可保证消息的传递,发布/订阅模式在消息的一对多广播时采用。Kafka同时支持两种消息投递模式,而这正是得益于消费者与消费者模式的契合:
- 如果所有的消费者都隶属于同一个消费组,那么所有的消息都会被均衡地投递给每一个消费者,即每条消息只会被一个消费者处理,这就相当于点对点模式的应用。
- 如果所有的消费者都隶属于不同的消费组,那么所有的消息都会被广播给所有的消费者,即每条消息会被所有的消费者处理,这就相当于发布/订阅模式的应用。
3.2 客户端开发
一个正常的消费逻辑需要具备以下几个步骤:
1、配置消费者客户端参数及创建相应的消费者实例
2、订阅主题
3、拉取消息并消费
4、提交消费位移
5、关闭消费者实例
3.2.1 必要的参数配置
- bootstrap.serviers:释义与生产者客户端KafkaProducer中的相同,指定连接Kafka集群所需的broker地址清单,形式为host1:port1,host2:post2,可以设置一个或多个
- group.id:消费者隶属的消费组的名称,默认值为“”。为空会抛出异常。一般会设置成具有一定的业务意义的名称。
- key.deserializer和value.deserializer:与生产者客户端KafkaProducer中的key.serializer和value.serializer参数对应。必须填写全限定名如org.apache.kafka.common.serialization.StringDeserializer。
- client.id:设定KafkaConsumer对应的客户端id,默认值也为""。不设置会自动生成一个非空字符串,如“consumer-1”“consumer-2”。
3.2.2 订阅主题与分区
3.2.3 反序列化
3.2.4 消息消费
Kafka中的消费是基于拉模式的。消息的消费一般有两种模式:推模式和拉模式。
Kafka中的消息消费是一个不断轮询的过程,消费者索要做的就是重复地调用poll()方法,而该方法返回的所订阅的主题(分区)上的一组消息。
3.2.5 位移提交
- 偏移量:消息在分区中的位置(存储层面)
- 位移(消费位移):消费者消费到的位置(消费层面)
位移提交(难点),采用自动提交,带来的问题:重复消费、消息丢失。
3.2.6 控制或关闭消费
3.2.7 指定位移消费
当消费者找不到消费位移时(如新的消费组),就会根据消费者客户端参数auto.offset.reset的配置来决定,默认值为”latest”,表示从分区末尾开始消费消息。如果
参数配置为"earliest",那么消费者会从起始处开始消费。
3.2.8 再均衡
再均衡是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便又安全地删除消费组内的消费者或往消费组内添加消费者。再均衡期间,消费者无法读取消息。
“重复消费”问题:再均衡之前消费了一次,之后又消费了一次。一般情况下应避免不必要的再均衡的发生。
再均衡监听器用来设定发生再均衡动作前后的一些准备或收尾的动作。
3.2.9 消费者拦截器
3.2.10 多线程实现
KafkaProducer是线程安全的,但KafaConsumer是非线程安全的。KafaConsumer中定义了一个acquire()方法,用来检测当前是否只有一个线程在操作,若有其它线程正在操作则会抛出异常。
方块大小和滑动窗口的大小同时决定了消费线程的并发数:一个方格对应一个消费线程,对于窗口大小固定的情况,方格越小并行度越高;对于方格大小固定的情况,窗口越大并行度越高。
3.2.11 重要的消费者参数
1、fetch.min.bytes:配置Consumer在一次拉取请求(调用poll()方法)中能从Kafka中拉取的最小数据量,默认值为1(B)。
2、fetch.max.bytes:与上个参数相反,默认值为52428800(B),也就是50MB。
3、fetch.max.wait.ms:
…
04 主题与分区
4.1 主题的管理
4.1.1 创建主题
4.1.2 分区副本的分配
4.1.3 查看主题
4.1.4 修改主题
问:为什么不支持减少分区?
答:按照kafka现有的代码逻辑,此功能完全可以实现,不过也会使代码的复杂度急剧增大。实现此功能需要考虑的因素很多,比如删除的分区中的消息该如何处理?如果随着分区一起消失则消息的可靠性得不到保障;如果需要保留则又需要考虑如何保留。直接存储到现有分区的尾部,消息的时间戳就不会递增,如此对于Spark、Flink这类需要消息时间戳(事件时间)的组件将会受到影响;如果分散插入现有的分区,那么在消息量很大的时候,内部的数据复制会占用很大的资源,而且在复制期间,此主题的可用性又如何得到保障?与此同时,顺序性问题、事务性问题,以及分区和副本的状态机切换问题都是不得不面对的。反观这个功能的受益点确是很低的,如果真的需要实现此类功能,则完全可以重新创建一个分区数较小的主题,然后将现有主题中的消息按照既定的逻辑复制过去即可。
4.1.5 配置管理
kafka-configs.sh脚本专门用来对配置进行操作。
4.1.6 主题端参数
4.1.7 删除主题
4.2 初识KafkaAdminClient
一般使用kafka-topic.sh脚本来管理主题,但有时候希望将主题管理类功能集成到公司内部的系统中,打造集管理、监控、运维、告警为一体的生态平台,那么需要以程序调用API的方式去实现。
4.2.1 基本使用
4.2.2 主题合法性验证
4.3 分区的管理
4.3.1 优先副本的选举
4.3.2 分区重分配
4.3.3 复制限流
4.3.4 修改副本因子
4.4 如何选择合适的分区数
根据实际的业务场景、软件条件、硬件条件、负载情况等来做具体的考量。
4.4.1 性能测试工具
Kafka本身提供的用于生产者性能测试的kafka-producer-perf-test.sh和用于消费者性能测试的kafka-consumer-perf-test.sh。
4.4.2 分区数越多吞吐量就越高吗
消息中间件的性能一般是指吞吐量(广义来说还包括延迟)。抛开硬件资源的影响,消息写入的吞吐量还会受到消息大小、消息压缩方式、消息发送方式(同步/异步)、消息确认类型(acks)、副本因子等参数的影响,消息消费的吞吐量还会受到应用逻辑处理速度的影响。
一般情况下,根据预估的吞吐量及是否与key相关的规则来设定分区数即可,后期可以通过增加分区数、增加broker或分区重分配等手段来进行改进。如果一定要一个准则,则建议将分区数设定为集群中broker的倍数,即假定集群中有3个broker节点,可以设定分区数为3、6、9等,至于倍数的选定可以参考预估的吞吐量。不过,如果集群中的broker节点数有很多,比如大几十或上百、上千,那么这种准则也不太适用,在选定分区数时进一步可以引入基架等参考因素。
05 日志存储
5.1 文件目录布局
不考虑多副本的情况,一个分区对应一个日志(Log)。为了防止Log过大,Kafka又引入了日志分段(LogSegment)的概念,将Log切分为多个LogSegment,相当于一个巨型文件被平均分配为多个相对较小的文件,这样也便于消息的维护和清理。事实上,Log和LogSegment也不是纯粹物理意义上的概念,Log在物理上只以文件夹的形式存储,而每个LogSegment对应于磁盘上的一个日志文件和两个索引文件,以及可能的其他文件(比如以“.txnindex”为后缀的事务索引文件)。
向Log中追加消息时是顺序写入的,只有最后一个LogSegment才能执行写入操作。在此之前所有的LogSegment都不能写入数据。为了方便描述,我们将最后一个LogSegment称为“activeSegment”,即表示当前活跃的的日志分段。
为了便于消息的检索,每个LogSegment中的日志文件(以“.log”为文件后缀)都有对应的两个索引文件:偏移量索引文件(以“.index”为文件后缀)和时间戳索引文件(以“.timeindex”为文件后缀)。每个LogSegment都有一个基准偏移量baseOffset,用来表示当前LogSegment中第一条消息的offerset。偏移量是一个64位的长整型数,日志文件和两个索引文件都是根据基准偏移量(baseOffset)命名的,名称固定为20位数字,没有达到的位数则用0填充。比如第一个LogSegment的基准偏移量为0,对应的日志文件为00000000000000000000.log。
5.2 日志格式的演变
Kafka的消息格式也经历了3个版本:v0版本、v1版本和v2版本。
5.2.1 v0版本
Kafka消息格式的第一个版本通常称为v0版本,在Kafka 0.10.0之前都采用的这个消息格式(在0.8.x版本之前还是用过一个更古老的消息格式,忽略)
5.2.2 v1版本
Kafka从0.10.0版本开始到0.11.0版本之前所使用的消息格式版本为v1,比v0版本就多了一个timestamp字段,表示消息的时间戳。
5.2.3 消息压缩
常见的压缩算法是数据量越大压缩效果越好,一条消息通常不会太大,这就导致压缩效果并不是太好。而Kafka实现的压缩方式是将多条消息一起进行压缩,这样可以保证较好的压缩效果。
5.2.4 变长字段
Kafka从0.11.0版本开始所使用的的消息格式版本为v2,这个版本的消息相比v0和v1的版本而言改动很大,同时还参考了Protocol Buffer而引入了变长整型(Varints)和ZigZag编码。
Varints是使用一个或多个字节来序列化整数的一种方法。数值越小,其占用的字节数就越少。Varints中的每个字节都有一个位于最高位的msb位(most significant bit),除最后一个字节外,其余msb位都设置为1,最后一个字节的msb位为0。
5.3 日志索引
偏移量索引文件用来建立消息偏移量(offset)到物理地址之间的映射关系,方便快速定位消息所在的物理文件位置;时间戳索引文件则根据指定的时间戳(timestamp)来查找对应的偏移量信息。
Kafka中的索引文件以稀疏索引(sparse index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。
稀疏索引通过MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度。偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找法来快速定位偏移量的位置,如果指定的偏移量不在索引文件中,则会返回小于指定偏移量的最大偏移量。时间戳索引文件中的时间戳也保持严格的单调递增,查询指定时间戳时,也根据二分查找法来查找不大于该时间戳的最大偏移量,至于要找到对应的物理文件位置还需要根据偏移量索引文件来进行再次定位。稀疏索引的方式是在磁盘空间、内存空间、查找时间等多方面之间的一个折中。
5.3.1 偏移量索引
偏移量索引项分为两个部分:
(1)relativeOffset:相对偏移量
(2)position:物理地址
5.3.2 时间戳索引
5.4 日志清理
Kafka提供了两种日志清理策略:
(1)日志删除(Log Retention)
(2)日志压缩(Log Compaction)
5.4.1 日志删除
日志分段的保留策略:
(1)基于时间
(2)基于日志大小
(3)基于日志起始偏移量
5.4.2 日志压缩
5.5 磁盘存储
5.5.1 页缓存
5.5.2 磁盘I/O流程
5.5.3 零拷贝
所谓的零拷贝是指将数据直接从磁盘文件复制到网卡设备中,而不需要经由应用程序之手。
5.6 总结
06 深入服务端
6.1 协议设计
6.2 时间轮
6.3 延时操作
6.4 控制器
6.4.1 控制器的选举及异常恢复
6.4.2 优雅关闭
6.4.3 分区leader的选举
6.5 参数解密
6.5.1 broker.id
6.5.2 bootstrap.servers
6.5.3 服务端参数列表
6.6 总结
07 深入客户端
7.1 分区分配策略
7.1.1 RangeAssignor分配策略
7.1.2 RoundRobinAssignor分配策略
7.1.3 StickyAssignor分配策略
7.1.4 自定义分区分配策略
7.2 消费者协调器和组协调器
7.2.1 旧版消费者客户端的问题
7.2.2 再均衡的原理
7.3 __consumer_offsets剖析
7.4 事务
7.4.1 消息传输保障
7.4.2 幂等
7.4.3 事务
7.5 总结
08 可靠性探究
8.1 副本剖析
8.1.1 失效副本
8.1.2 ISR的伸缩
8.1.3 LEO与HW
8.1.4 Leader Epoch的介入
8.1.5 为什么不支持读写分离
(1)数据一致性问题
(2)延时问题
8.2 日志同步机制
8.3 可靠性分析
8.4 总结
09 Kafka应用
9.1 命令行工具
9.1.1 消费组管理
9.1.2 消费位移管理
9.1.3 手动删除消息
9.2 kafka Connect
Kafka Connect是一个工具,它为在Kafka和外部数据存储系统之间移动数据提供了一种可靠的且可伸缩的实现方式。
Kafka Connect有两个核心概念:Source和Sink。Source负责导入数据到Kafka,Sink负责从Kafka导出数据,它们都被称为Connector(连接器)。
两个重要概念:Task和Worker。Task是Kafka Connect数据模型的主角,每一个Connector都会协调一系列的Task去执行任务,Connector可以把一项工作分割成许多Task,然后把Task分发到各个Worker进程中去执行(分布式模式下),Task不保存自己的状态信息,而是交给特定的Kafka主题去保存。Connector和Task都是逻辑工作单位,必须安排在进程中执行,而在Kafka Connect中,这些进程就是Worker。
Kafka Connect提供了以下特性:
- 通用性:规范化其他数据系统与Kafka的继承,简化了连接器的开发、部署和管理。
- 支持独立模式(standalone)和分布式模式(distributed)。
- REST接口:使用REST API提交和管理Connector。
- 自动位移管理:自动管理位移提交,不需要开发人员干预,降低了开发成本。
- 分布式和可扩展性:Kafka Connect基于现有的组管理协议来实现扩展Kafka Connect集群。
- 流式计算/批处理的集成。
9.2.1 独立模式
Kafka中的connect-standalone.sh脚本用来实现以独立的模式运行Kafka Connect。
9.2.2 REST API
默认端口号为8083,可以通过Worker进程的配置文件中的rest.port参数来修改端口号。
9.2.3 分布式模式
以分布式模式启动的连接器并不支持在启动时通过加载连接器配置文件来创建一个连接器,只能通过访问REST API来创建连接器。
9.4 Kafka Streams
Kafka实现了高吞吐、高可用和低延时的消息传输能力,这让它成为流式处理系统中完美的数据来源。目前通用的一些流式处理框架如Apache Spark、Apache Flink、Apache Storm等都可以将Kafka作为可靠的数据来源。但遗憾的是,在0.10.x版本之前,Kafka还并不具备任何数据处理的能力,但在此之后,Kafka Streams应运而生。
Kafka Streams直接解决了流式处理中的很多问题
- 毫秒级延迟的逐个事件处理
- 有状态的处理,包括连接(join)和聚合类操作
- 提供了必要的流处理原语,包括高级流处理DSL和低级处理器API。高级流处理DSL提供了常用流处理变换操作,低级处理器API支持客户端自定义处理器并与状态仓库交互
- 使用类似DataFlow的模型对无序数据进行窗口化处理
- 具有快速故障切换的分布式处理和容错能力
- 无停机滚动部署
9.5 总结
10 Kafka监控
以Kafka manager为例,它提供的监控功能也是相对比较完善的,在实际应用中具有很高的使用价值。但有一个遗憾就是其难以和公司内部系统平台关联,对于业务资源的使用情况、相应的预防及告警的联动无法顺利贯通。
10.1 监控数据的来源
10.1.1 OneMinuteRate
10.1.2 获取监控指标
10.2 消费滞后(Lag)
消息堆积是消息中间件的一大特色,消息中间件的流量削峰、冗余存储等功能正是得益于消息中间件的消息堆积能力。这是一把双刃剑。有些中间件如RabbitMQ在发生消息堆积时还会影响自身的性能。对Kafka而言,虽然消息堆积不会给其自身性能带来太大的困扰,但难免会影响上下游的业务,堆积过多有可能造成磁盘爆满,或者触发日志清楚操作而造成消息丢失的情况。
(1)普通情况:
Lag = HW - ConsumerOffset(消费位移)
(2)引入事务:
1)消费者客户端的isolation.level参数配置为read_uncommitted(默认),Lag计算方式不受影响。
2)上述参数配置为read_commmitted,引入LSO来计算。LSO是LastStableOffset的缩写。
对未完成的事务而言,LSO的值等于事务中第一条消息的位置(firstUnstableOffset);
Lag = LSO - ConsumerOffset
对已完成的事务而言,它的值同HW相同,结论:LSO<=HW<=LEO。
10.3 同步失效分区
消费Lag是Kafka的普通使用者特别关心的一项指标,而同步失效分区(under-replicated)的多少是Kafka运维人员非常关心的一项指标。8.1.1节中的概念:处于同步失效或功能失效(比如处于非活跃状态)的副本统称为失效副本。而包含失效副本的分区也就称为同步失效分区。
Kafka本身提供了一个相关的指标来表征失效分区的个数,即UnderReplicatedPartitions,可以通过JMX访问来获取其值:
kafka.server:type=ReplicaManager,name=UnderRelicatedPartitions
注意:如果Kafka集群正在做分区重分配(参考4.3.2节),该值也大于0.
如果集群中有多个broker的UnderReplicatedPartitions保持一个大于0的稳定值,则一般暗示集群中有broker已经处于下线状态。该情况下,这个broker中的分区个数与集群中的所有UnderRepliatedPartitions(处于下线的broker是不会上报任何指标值得)之和是相等的。通常这类问题是由于机器硬件原因引起的,但也有可能是由于操作系统或JVM引起的,可以往这个方向继续做进一步的深入调查。
如果集群中存在broker的UnderReplicatedPartitions频繁变动,或者处于一个稳定的大于0的值(这里特指没有broker下线的情况)时,一般暗示集群出现了性能问题。确定某个broker,然后针对单一的broker做专项调查,比如操作系统、GC、网络状态或磁盘状态(如iowait、ioutil等指标)。
如果多个broker中都出现了under-replicated分区,则一般是整个集群的问题,但也有可能是单个broker出现了问题。对于后者,如果单个broker在消息同步方面出了问题,那么其上的follower副本就无法及时有效地与其他broker上的leader副本进行同步,就出现了多个broker都存在under-replicated分区的现象。
集群层面的问题一般也就是两个方面:资源瓶颈和负载不均衡。资源瓶颈指的是broker在某硬件资源的使用上遇到了瓶颈,比如网络、CPU、I/O等层面。就以I/O而论,Kafka中的消息都是存盘的,生产者线程将消息写入leader副本的性能和I/O有着直接的关联,follower副本的同步线程及消费者的消费线程又要通过I/O从磁盘中拉取消息,如果I/O层面出现了瓶颈,那么势必影响全局的走向,与此同时消息的流入/流出又都需要和网络打交道。建议硬件层面的指标可以关注CPU的使用率、网络流入/流出速度、磁盘的读/写速度、iowait、ioutil等,也可以适当地关注下文件句柄数、Socket句柄数及内存等方面。
10.4 监控指标说明
Kafka自身提供的JMX监控指标已经超过了500个,这里挑选部分重要及常用指标进行说明。
10.5 监控模块
Kafka的监控架构主要分为数据采集、数据存储和数据展示这3个部分。数据采集主要指从各个数据源采集监控数据并做一些必要的运算,然后发送给数据存储模块进行存储。数据源可以是Kafka配套的Zookeeper、Kafka自身提供的内部运行指标(通过JMX获取)、Kafka内部的一些数据(比如__consumer_ooffset中存储的信息,通过Kafka自定义协议获取)、Falcon/Zabbix等第三方工具(或者其他类似的工具、主要用来监控集群的硬件指标)。
数据存储可以采用OpenTSDB之类的基于时间序列的数据库,方便做一些聚合计算,也可以附加采用Redis、MySQL等存储特定数据。
10.6 总结
11 高级应用
高级应用类的需求,比如消费回溯,可以通过原生Kafka提供的KafkaConsumer.seek()方法来实现,然而类似延迟队列、消息轨迹等应用需求在原生Kafka中就没有提供了。本章讲述如何扩展类高级应用。
11.1 过期时间(TTL)
11.2 延时队列
11.3 死信队列和重试队列
11.4 消息路由
11.5 消息轨迹
11.6 消息审计
11.7 消息代理
11.7.1 快速入门
11.7.2 REST API介绍及示例
11.7.3 服务端配置及部署
11.7.4 应用思考
11.8 消息中间件选型
11.8.1 各类消息中间件简述
11.8.2 选型要点概述
11.8.3 消息中间件选型误区探讨
11.9 总结
12 Kafka与Spark的集成
12.1 Spark的安装及简单应用
12.2 Spark编程模型
12.3 Spark的运行结构
12.4 Spark Streaming简介
12.5 Kafka与Spark Streaming的整合
12.6 Spark SQL
12.7 Structured Streaming
12.8 Kafka与Structured Streaming的整合
12.9 总结
附录A Kafka源码环境搭建
13.2 - Pulsar
背景
Pulsar介绍
Apache Pulsar 是一个云原生、分布式的流式处理和消息队列的平台。
关键特性:
- 云原生架构(计算与存储分离),无缝支持跨集群复制
- 比kafka更高的吞吐量和低延迟
- 无缝支持上百万个topics支持多种消息订阅模式 (exclusive & shared & failover)
- 通过持久化存储BookKeeper保障消息的传递
- 轻量级Serverless计算框架Pulsar Functions提供了流式数据处理能力。
- 提供分层存储能力,释放BookKeeper的空间:将老数据or长期不用的数据放到AWS S3等
传统MQ问题
以 Kafka 举例,Kafka 把 broker 和 partition 的数据存储牢牢绑定在一起,会产生很多问题。

数据全量复制
Kafka 的很多操作都涉及 partition 数据的全量复制。
扩容broker 假设有 broker1, broker2 两个节点,分别负责若干个 partition,如果新加入一个 broker3 节点分摊 broker1 的部分负载,那么 broker1 得分出一些 partition 给到 broker3。必须复制 partition 的全量数据之后,broker3 才能提供服务。不仅消耗 IO 以及网络资源,且如果复制数据的速度小于 partition 的写入速度,那就无法完成复制。
增加follower副本 新增 follower 副本必须要跟 leader 副本同步全量数据。
partition迁移 如果某些 partition 中的数据特别多(数据倾斜),对应 broker 的磁盘可能很快被写满,会涉及到 partition 的迁移,数据复制无法避免。
虽然 Kafka 提供了现成的脚本可以完成这些事情,但实际面临的问题较多,操作也较复杂,数据迁移也是一件耗时耗力的事,远远做不到集群级别的自动的平滑操作。
依赖Page Cache
Page Cache 实际上就是文件的读写缓存,Linux 文件系统会利用该机制优化性能,写入数据时 Linux 返回成功,实际上数据并没有真正写入磁盘中,而是写到了 Page Cache 缓存中,等后续刷新写入到磁盘中。当出现断电等系统故障时,如果缓存中数据没有写入磁盘中,那么会出现数据丢失的情况。
Kafka 底层完全依赖 Page Cache,没有要求 Linux 强制刷新磁盘,对于一些强一致的需求场景是不可接受的,如金融、电商等。
另外 Page Cache 也是有可能出现性能问题的。消费者消费数据可以分为以下两种情况:
- 追尾读(Tailing Reads) 定义:就是消费者的消费速度很快,生产者生产消息,消费者立刻能够消费。
broker处理过程:生产者生产消息,broker 写入到 Page Cache 写缓存,消费者立刻来读取消息,这时 broker 可以快速从 Page Cache 读取消息发送给消费者。
- 追赶读(Catch-up Reads) 定义:消费者的消费速度很慢,生产者生产了很多新消息,但消费者还在读取比较旧的消息。
broker处理过程:Page Cache 缓存里没有消费者想读取的旧消息,broker 必须从磁盘中读取数据并存储在 Page Cache 读缓存中。这样读写操作都依赖 Page Cache,就会导致读写操作会互相影响,对一个 partition 的大量读可能影响写性能,大量写也可能会影响读性能,并且读写缓存会互相争抢内存资源,可能会造成 IO 性能问题。
架构设计
基于传统 MQ 的一些问题,Pulsar 在系统架构上做了一些重新设计,如计算存储分离、读写分离。
分层架构
Broker无状态层
与kafka不同,Pulsar Broker不存储实际的数据,而是将消息存储在 BookKeeper 中,仅仅拥有 Topic/Partitions 的代理权。屏蔽了 message 复杂的读写流程,保证了数据一致性和负载均衡。meta 信息是存储在 zookeeper 中,消息存储到 BookKeeper 中。
BookKeeper存储层
BookKeeper 是一个分布式的预写日志(WAL)系统,Pulsar 使用 Apache BookKeeper 作为持久化存储。
主要特性有:
- 支持创建多个独立的 ledgers(Fragment/Segment)随着时间的推移,底层数据以 Ledger形式存储,Pulsar会为Topic创建多个 ledgers。
- 为按条目复制的顺序数据提供了非常高效的存储。
- 保证了多系统挂掉时ledgers的读取一致性。
- 提供不同的Bookies(BookKeeper实例)均匀的IO分布的特性。
- 容量和吞吐量都能水平扩展。并且容量可以通过在集群内添加更多的Bookies立刻提升。
- 被设计成可以承载数千的并发读写的ledgers。 使用多个磁盘设备,一个用于日志,另一个用于一般存储,可以将读操作的影响和对于写操作的延迟分隔开。
计算存储分离
解决的问题:避免 broker 扩容时 partition 的数据迁移。
解决方案:原来 broker 同时负责计算(提供服务给生产者和消费者)和存储(消息的持久化)。Plusar 将两者分离,改用多层的计算存储分离架构,broker 只负责计算,将存储交给底层存储引擎 Bookkeeper。
Kafka 中可以把每个 partition 理解成一个存储消息的大文件,在 broker 之间转移 partition 需要复制数据。在 Pulsar 中可以把每个 partition 理解成一个文件描述符,broker 只需要持有该文件描述符即可,可以把数据的处理全部交给存储引擎 Bookkeeper。且因为 broker 是无状态的,可以非常方便地借助 kubernetes 实现弹性扩缩容等。
如果某个 broker 节点压力很大,增加 broker 节点去分担 partition 即可。与之类似的如果某个 broker 节点宕机,直接转移 partition 到其它 broker 即可,这些操作都不涉及数据复制。
对等节点
Kafka 使用主从复制的方式实现高可用;Bookkeeper 采用 Quorum 机制实现高可用。
Bookkeeper 集群由若干 bookie 节点(运行着 bookie 进程的服务器)组成的,和 Kafka 的主从复制机制不同,这些 bookie 节点都是对等的没有主从之分。
数据写入方式,条带化写入:当 broker 要求 Bookkeeper 集群存储一条消息(entry)时,该消息会被并发地同时写入多个 bookie 节点进行存储。之后的消息会以滚动的方式选取不同的 bookie 节点进行写入。
这种写入方式既实现了数据的冗余存储,又使得数据能够均匀分布在多个存储节点上,从而避免数据倾斜导致某个存储节点压力过大。
对等节点的好处在于可以进行快速故障恢复和扩容。
Bookkeeper 中维护了类似下面的一组元数据:
| |
这组元数据的含义是:entry0 ~ entry99 都写到了 bookie1, bookie2, bookie3 中,entry100及之后的消息都写到了 bookie1, bookie3, bookie4 中。 这组元数据记录了每条 entry 具体的位置,即便 bookie2 节点故障,还有其它节点如 bookie1, bookie3 节点可以读取。
读写分离
bookie 节点实现读写隔离,不再依赖操作系统的 Page Cache,而是自行维护缓存,保证了数据可靠性和高性能。

每个 bookie 节点都拥有两块磁盘,其中 Journal 磁盘专门用于写入数据,Entry Log 磁盘专门用于读取数据,而 memtable 是 bookie 节点自行维护的读写缓存。
其中 Journal 盘的写入不依赖 Page Cache,直接强制刷盘(可以配置),写入完成后 bookie 节点就会返回 ACK 写入成功。
写 Journal 盘的同时,数据还会在 memotable 缓存中写一份,memotable 会对数据进行排序,一段时间后刷入 Entry Log 盘。
这样不仅多了一层缓存,而且 Entry Log 盘中的数据有一定的有序性,在读取数据时可以一定程度上提高性能。
写流程
Broker 数据写入流程如下:
- 将写请求记入 WAL
一般工程实践上建议把 WAL 和数据存储文件分别存储到两种存储盘上,如把 WAL 存入一个 SSD 盘,而数据文件存入另一个 SSD 或者 SATA 盘。
- 将数据写入内存缓存中
- 写缓存写满后,进行数据排序并进行 Flush 操作,排序时将同一个 Ledger 的数据聚合后以时间先后进行排序,以便数据读取时快速顺序读取;
- 将 <(LedgerID, EntryID), EntryLogID> 写入 RocksDB。
LedgerID 相当于 kafka 的 ParitionID,EntryID 即是 Log Message 的逻辑 ID,EntryLogId 就是 Log消息在 Pulsar Fragment文件的物理 Offset。 这里把这个映射关系存储 RocksDB 只是为了加快写入速度,其自身并不是 Pulsar Bookie 的关键组件。
读流程
Bookie 数据读取流程如下:
- 从写缓存读取数据,写缓存有最新的数据;
- 如果写缓存不命中,则从读缓存读取数据;
- 如果读缓存不命中,则根据 RocksDB 存储的映射关系查找消息对应的物理存储位置,然后从磁盘上读取数据;
- 把从磁盘读取的数据回填到读缓存中;
- 返回数据给 Broker。
优缺点
这样设计的缺点是一份数据要存两次,消耗磁盘空间,但优势也很明显:
1、可靠性得到保证,不会丢失数据。因为 Journal 落盘后才判定为写入成功,即使机器断电数据也不会丢失。
2、数据读写不依赖操作系统的 Page Cache,即便读写压力较大,也可以保证稳定的性能。
3、可以灵活配置。Journal 盘的数据可以定时迁出,可以采用存储空间较小但写入速度快的存储设备来提高写入性能。
对比Kafka
名词对应表
| Pulsar | Kafka |
|---|---|
| Topic | Topic |
| Partition | Partition |
| Ledger(Segment)/Fragment | Fragment/Segment |
| Bookie | Broker |
| Broker | Client SDK |
| Ensemble Size | metadata.broker.list |
| Write Quorum Size (Qw) | Replica Number |
| Ack Quorum Size (Qa) | request.required.acks |
优劣势
优势:
计算存储分离,避免数据拷贝
无状态:可以快速扩容、故障恢复
更多的特性支持:Pulsar 提供了很多与 Kafka 相似的特性,比如跨域复制、流式消息处理(Pulsar Functions)、连接器(Pulsar IO)、基于 SQL 的主题查询(Pulsar SQL)、schema registry,还有一些 Kafka 没有的特性,比如分层存储和多租户。 劣势:
Pulsar 项目较新,社区活跃不足,而 Kafka 更加成熟,社区更活跃
kafka 仅依赖 broker 和 zookeeper,而 Pulsar 还依赖 bookKeeper,增加了系统的复杂性。
Reference
https://alexstocks.github.io/html/pulsar.html
比拼 Kafka, 大数据分析新秀 Pulsar 到底好在哪
13.3 - RocketMQ源码分析
源码环境搭建
源码下载
https://github.com/apache/rocketmq
可直接clone或下载压缩包,这里以 release-4.7.1 版本为例分析。
模块启动
主要模块:
1、broker:broker 模块
2、client:客户端(生产者、消费者)
3、example:示例代码
4、namesrv:NameServer 实现相关类
Tips:配置关键代码提示,类似 todo、fixme
在 IDEA 下方的 TODO 中,点击 Filter 图标的 Edit Filters。配置 Patterns,如 \bk1 、 \bk2 、 \bkkk 等;配置 Filters ,选择 刚才配置的 patterns。
参考链接:https://www.jetbrains.com.cn/help/idea/using-todo.html
源码调试
Execute Maven Goal(IDEA 右边栏 maven 图标)
| |
配置文件
在根目录下新建 conf 文件夹,将 distribution/conf 下 broker.conf、logback_broker.xml、logback_namesrv.xml、logback_tools.xml 拷贝到该目录下。
启动 namesrv
| |
启动 broker
| |
启动 consumer
| |
启动 producer
| |
NameSrv
NameServer整体结构

Broker
Broker架构图

Broker 注册

sendDefaultImpl
| |
checkMessage
| |
tryToFindTopicPublishInfo
| |
selectOneMessageQueue
| |
Message Queue选择机制
updateFaultItem
| |
容错机制下的选择逻辑
Producer
Producer 功能
Producer 有两种:
1、普通发送者 DefaultMQProducer。只需要构建一个 Netty 客户端,往 Broker 发送消息即可。
注意:异步回调只是在 Producer 接收到 Broker 的响应后自行调整流程,不需要提供 Netty 服务。
2、事务消息发送者 TransactionMQProducer。需要构建一个 Netty 客户端,往 Broker 发送消息。同时也要构建 Netty 服务端,供 Broker 回查本地事务状态。
Producer 总体流程


检查消息合法性
org.apache.rocketmq.client.Validators#checkMessage
- Topic名称中是否包含了非法字符
- Topic名称长度是否超过了最大的长度限制,由常量TOPIC_MAX_LENGTH来决定,其默认值为127
- 当前消息体是否是NULL或者是空消息
- 当前消息体是否超过了最大限制,由常量maxMessageSize决定,值为1024 * 1024 * 4,也就是4M。

获取Topic的详情
| |
当通过了消息的合法性校验之后,需要知道应该把消息发送给谁,此时就需要通过当前消息所属的Topic拿到Topic的详细数据。

获取Topic的方法源码在上面已经给出来了,首先会从内存中维护的一份Map中获取数据。顺带一提,这里的Map是ConcurrentHashMap,是线程安全的,和Golang中的Sync.Map类似。
当然,首次发送 Map 肯定是空的,此时会调用NameServer的接口,通过Topic去获取详情的Topic数据,会在上面的方法中将其加入到Map中去,这样一来下次再往该Topic发送消息就能够直接从内存中获取。这里就是简单的实现的缓存机制 。
从方法名称来看,是通过Topic获取路由数据。实际上该方法,通过调用NameServer提供的API,更新了两部分数据,分别是:
1)Topic路由信息
2)Topic下的Broker相关信息
而这两部分数据都来源于同一个结构体TopicRouteData。
注意 TopicPublishInfo 这个结构体,包含 TopicRouteData
Producer 路由信息
当获取到了需要发送到的Broker详情,包括地址和MessageQueue,那么此时问题的关注点是具体发送到哪一个Message Queue中去。
核心代码:
| |
路由信息的管理流程

Producer 负载均衡
默认会把消息平均地发送到所有 MessageQueue 里,这时设计到 Message Queue 的选择机制
MessageQueue 核心选择逻辑
流程图

核心逻辑,用大白话讲就是将一个随机数和 Message Queue(TopicPublishInfo中)的容量取模。这个随机数存储在 Thread Local 中,首次计算的时候,会直接随机一个数。
| |
可以看到,主逻辑被变量 sendLatencyFaultEnable 分为了两部分。
容错机制下的选择逻辑
该变量表意为发送延迟故障。本质上是一种容错的策略,在原有的MessageQueue选择基础上,再过滤掉不可用的Broker,对之前失败的Broker,按一定的时间做退避。

| |
可以看到,如果调用Broker信息发生了异常,那么就会调用 updateFault 这个方法,来更新Broker 的 Aviable 情况。注意这个参数 isolation 的值为 true。接下来从源码级别来验证上面说的退避 3000ms 的事实。 可以看到,isolation 值是 true,则 duration 通过三元运算符计算出来结果为 30000,也就是 30 秒。所以可以得出结论,如果发送消息抛出了异常,那么直接会将该 Broker 设置为 30 秒内不可用。
而如果只是发送延迟较高,则会根据如下的 map,根据延迟的具体时间,来判断该设置多少时间的不可用。
正常情况下的选择逻辑
而正常情况下,如果当前发送故障延迟没有启用,则会走常规逻辑,同样的会去for循环计算,循环中取到了 MessageQueue 之后会去判断是否和上次选择的 MessageQueue 属于同一个Broker,如果是同一个 Broker,则会重新选择,直到选择到不属于同一个 Broker 的 MessageQueue,或者直到循环结束。这也是为了将消息均匀的分发存储,防止数据倾斜。

发送消息
选到了具体的Message Queue之后就会开始执行发送消息的逻辑,就会调用底层Netty的接口给发送出去,这块暂时没啥可看的。
Broker的启动流程
主从同步
在上面提到过,RocketMQ有自己的主从同步,但是有两个不同的版本,版本的分水岭是在4.5版本。这两个版本区别是什么呢?
1)4.5之前:有点类似于Redis中,我们手动的将某台机器通过命令slave of 变成另一台Redis的Slave节点,这样一来就变成了一个较为原始的一主一从的架构。为什么说原始呢?因为如果此时Master节点宕机,我们需要人肉的去做故障转移。RocketMQ的主从架构也是这种情况。
2)4.5之后:引入了Dleger,可以实现一主多从,并且实现自动的故障转移。这就跟Redis后续推出了Sentinel是一样的。Dleger也是类似的作用。
下图是Broker启动代码中的源码。

可以看到判断了是否开启了Dleger,默认是不开启的。所以就会执行其中的逻辑。
刚好我们就看到了,里面有Rocket主从同步数据的相关代码。

如果当前Broker节点的角色是Slave,则会启动一个周期性的定时任务,定期(也就是10秒)去Master Broker同步全量的数据。同步的数据包括:
1)Topic的相关配置
2)Cosumer的消费偏移量
3)延迟消息的Offset
4)订阅组的相关数据和配置
注册Broker
完成了主动同步定时任务的启动之后,就会去调用registerBrokerAll去注册Broker。可能这里会有点疑问,我这里是Broker启动,只有当前一个Broker实例,那这个All是什么意思呢?
All是指所有的NameServer,Broker启动的时候会将自己注册到每一个NameServer上去。为什么不只注册到一个NameServer就完事了呢?这样一来还可以提高效率。归根结底还是高可用的问题。
如果Broker只注册到了一台NameServer上,万一这台NameServer挂了呢?这个Broker对所有客户端就都不可见了。实际上Broker还在正常的运行。
进到registerBrokerAll中去。

可以看到,这里会判断是否需要进行注册。通过上面的截图可以看到,此时forceRegister的值为true,而是否要注册,决定权就交给了needRegister
为什么需要判断是否需要注册呢?因为Broker一旦注册到了NameServer之后,由于Producer不停的在写入数据,Consumer也在不停的消费数据,Broker也可能因为故障导致某些Topic下的Message Queue等关键的路由信息发生变动。
这样一来,NameServer中的数据和Broker中的数据就会不一致。
如何判断是否需要注册
大致的思路是,Broker会从每一个NameServer中获取到当前Broker的数据,并和当前Broker节点中的数据做对比。但凡有一台NameServer数据和当前Broker不一致,都会进行注册操作。

接下来,我们从源码层面验证这个逻辑。关键的逻辑在图中也标注了出来。

可以看到, 就是通过对比Broker中的数据版本和NameServer中的数据版本来实现的。这个版本,注册的时候会写到注册的数据中存入NameServer中。
这里由于是有多个,所以RocketMQ用线程池来实现了多线程操作,并且用CountDownLatch来等待所有的返回结果。经典的用空间换时间,Golang里面也有类似的操作,那就是sync.waitGroup。
关于任何一个数据不匹配,都会进行重新注册的事实,我们也从源码层面来验证一下。

可以看到,如果任何一台NameServer的数据发生了Change,都会break,返回true。
这里的结果列表使用的是CopyOnWriteList来实现的。

因为这里是多线程去执行的判断逻辑,而正常的列表不是线程安全的。CopyOnWriteArrayList之所以是线程安全的,这归功于COW(Copy On Write),读请求时共用同一个List,涉及到写请求时,会复制出一个List,并在写入数据的时候加入独占锁。比起直接对所有操作加锁,读写锁的形式分离了读、写请求,使其互不影响,只对写请求加锁,降低了加锁的次数、减少了加锁的消耗,提升了整体操作的并发。
执行注册逻辑
这块就是构建数据,然后多线程并发的去发送请求,用CopyOnWriteArrayList来保存结果。不过,上面我们提到过,Broker注册的时候,会把数据版本发送到NameServer并且存储起来,这块我们可以看看发送到NameServer的数据结构。

可以看到,Topic的数据分为了两部分,一部分是核心的逻辑,另一部分是DataVersion,也就是我们刚刚一直提到的数据版本。
消息存储
源码入口:
| |
Commit log
流程图

Producer 发送的消息是存储在一种叫 commit log 的文件中的,Producer 端每次写入的消息是不等长的,当该 CommitLog 文件写入满 1G,就会新建另一个新的 CommitLog,继续写入。此次采取的是顺序写入。
那么问题来了,Consumer 来消费的时候,Broker 是如何快速找到对应的消息的呢?首先排除遍历文件查找的方法, 因为 RocketMQ 是以高吞吐、高性能著称的,肯定不可能采取这种对于很慢的操作。那RocketMQ是如何做的呢?答案是 ConsumerQueue。
ConsumerQueue
ConsumerQueue是什么?是文件。引入的目的是什么呢?提高消费的性能。
Broker在收到一条消息的时候,写入Commit Log的同时,还会将当前这条消息在commit log中的offset、消息的size和对应的Tag的Hash写入到consumer queue文件中去。
每个 MessageQueue 都会有对应的 ConsumerQueue 文件存储在磁盘上,每个 ConsumerQueue 文件包含了30W条消息,每条消息的 size 大小为 20 字节,包含了 8 字节 CommitLog 的 Offset、4 字节的消息长度、8 字节的 Tag 的哈希值。这样一来,每个ConsumerQueue 的文件大小就约为5.72M。

当该 ConsumerQueue 文件写满了之后,就会再新建一个 ConsumerQueue 文件,继续写入。
所以,ConsumerQueue 文件可以看成是 CommitLog 文件的索引。
文件同步刷盘与异步刷盘
源码入口:
| |
其中主要涉及到是否开启了对外内存 TransientStorePoolEnable。如果开启了堆外内存,会在启动时申请一个跟 CommitLog 文件大小一致的堆外内存,这部分内存可以确保不会被交换到虚拟内存中。
过期文件删除
入口:在 DefaultMessageStore 的 start 方法中调用
| |
默认情况下,Broker 会启动后台线程,每 60 秒检查 CommitLog、ConsumeQueue 文件。然后对超过 72 小时的数据进行删除。也就是说,默认情况下,RocketMQ 只会保存 3 天内的数据。这个时间可以通过 fileReservedTime 来配置。注意删除时,并不会检查消息是否被消费了。
整个文件存储的核心入口在 DefaultMessageStore 的 start 方法中。

文件存储小结
RocketMQ 的存储文件包括消息文件(CommitLog)、消息消费队列文件(ConsumerQueue)、Hash 索引文件(IndexFile)、监测点文件(checkPoint)、abort(关闭异常文件)。单个消息存储文件、消息消费队列文件、Hash 索引文件长度固定以便使用内存映射机制进行文件的读写操作。
RocketMQ 组织文件以文件的起始偏移量来命名文件,这样能够根据偏移量快读定位到真实的物理文件。RocketMQ 基于内存映射文件机制提供了同步刷盘和异步刷盘两种机制,异步刷盘是指在消息存储时先追加到内存映射文件,然后启动专门的刷盘线程定时将内存中的文件数据刷写到磁盘。
CommitLog,消息存储文件,RocketMQ 为了保证消息发送的高吞吐量,采用单一文件存储所有主题消息,保证消息存储时完全的顺序写,但这样给文件读取带来了不便,为此 RocketMQ 为了方便消息消费构建了消息消费队列文件,基于主题与队列进行组织,同时 RocketMQ 为消息实现了 Hash 索引,可以为消息设置索引键,根据索引能够快速从 CommitLog 文件中检索消息。
Consumer
消费者功能
1、消费者分为 拉模式消费者 和 推模式消费者。
消费者的使用过程也跟生产者差不多,都是先 start 然后开始消费
2、消费者以消费者组的模式开展。消费者组之间有集群模式和广播模式两种消费模式。
3、消费者负载均衡,即消费者如何绑定消费队列的
4、推模式的消费者,MessageListenerConcurrently 和 MessageListenerOrderly 两种消息监听器的处理逻辑,为什么后者能否保证消息顺序
consumer 启动
源码入口:
| |
PullMessageService 主要处理拉取消息服务,RebalanceService 主要处理客户端的负载均衡。
消费模型
在Consumer中,默认都是采用集群消费,这块在Consumer的代码中也有体现。

而消费模式的不同,会影响到管理offset的具体实现。

可以看到,当消费模型是广播模式时,Offset的持久化管理会使用实现LocalFileOffsetStorage
当消费模式是集群消费时,则会使用RemoteBrokerOffsetStore。
具体原因是什么呢?首先我们得知道广播模式和集群模式的区别在哪儿:
1)广播模式下,一条消息会被ConsumerGroup中的每一台机器所消费
2)集群模式下,一条消息只会被ConsumerGroup中的一台机器消费
所以在广播模式下,每个ConsumerGroup的消费进度都不一样,所以需要由Consumer自身来管理Offset。而集群模式下,同个ConsumerGroup下的消费进度其实是一样的,所以可以交由Broker统一管理。
消费模式
消费模式则分为顺序消费和并发消费,分别对应实现MessageListenerOrderly和MessageListenerConcurrently两种方式。

不同的消费方式会采取不同的底层实现,配置完成之后就会调用start。
消息拉取
拉模式:PullMessageService
PullRequests 里有 messageQueue 和 processQueue,其中 messageQueue 负责拉取消息,拉取后将消息存入 processQueue,进行处理。存入后就可以清空 messageQueue,继续拉取。


接下来来看一个跟最最相关的问题,那就是平时消费的消息到底是怎么样从 Broker 发到的 Consumer。在靠近启动 Rebalance 的地方,Consumer 也开启了一个定时拉取消息的线程。

这个线程做了什么事呢?它会不停的从一个维护在内存中的Queue中获取一个在写入的时候就构建好的 PullRequest 对象,调用具体实现去不停的拉取消息了。

处理消息结果
在这里是否开启AutoCommit,所做的处理差不了很多,大家也都知道,唯一区别就在于是否自动的提交Offset。对于处理成功的逻辑也差不多,我们平时业务逻辑中可能也并不关心消费成功的消息。我们更多关注的是如果消费失败了,RocketMQ是怎么处理的?

这是在AutoCommit下,如果消费失败了的处理逻辑。会记录一个失败的TPS,然后这里有一个非常关键的逻辑,那就是checkReconsumeTimes。

如果当前消息的重试次数,如果大于了最大的重试消费次数,就会把消费发回给Broker。那最大重试次数是如何定义的。

如果值为-1,那么最大次数就是MAX_VALUE,也就是2147483647。这里有点奇怪啊,按照我们平常的认知,难道不是重试16次吗?然后就看到了很骚的一句注释。

-1 means 16 times,这代码确实有点,一言难尽。
然后,如果超过了最大的次数限制,就会将该消息调用Prodcuer的默认实现,将其发送到死信队列中。当然,死信队列也不是什么特殊的存在,就是一个单独的Topic而已。

通过getRetryTopic来获取的,默认是给当前的ConsumerGroup名称加上一个前缀。
客户端负载均衡策略
在消费者示例的 start 方法中,启动 RebalanceService,这个是客户端进行负载均衡策略的启动服务。只负责根据负载均衡策略获取当前客户端分配到的 MessageQueue 示例。
5 种负载均衡策略,可以由 Consumer 的 allocateMessageQueueStrategy 属性来选择。
最常用的是 AllocateMessageQueueAveragely 平均分配和 AllocateMessageQueueAveragelyByCircle 平均轮询分配。
平均分配是把 MessageQueue 按组内的消费者个数平均分配。
而平均轮询分配就是把 MessageQueue 按组内的消费者一个一个轮询分配。
举例:6 个队列 q1、q2、q3、q4、q5、q6,分配给 3 个消费者 c1、c2、c3。
平均分配的结果就是:c1:{q1, q2}, c2:{q3, q4}, c3:{q5, q6};
平均轮询分配的结果就是:c1:{q1, q4}, c2:{q2 q5}, c3:{q3, q6}。
并发消费与顺序消费
消费过程依然是 DefaultMQPushConsumerImpl 的 consumeMessageService。它有 2 个子类 ConsumeMessageServiceConcurrentlyService 和 ConsumeMessageOrderlyService。其中最主要的差别是 ConsumeMessageOrderlyService 会在消费前把队列锁起来,优先保证拉取同一个队列里的消息。
消费过程的入口在 DefaultMQPushConsumerImpl 的 pullMessage 中定义的 PullCallBack 中。
负载均衡
什么意思呢?假设我们总共有6个MessageQueue,然后此时分布在了3台Broker上,每个Broker上包含了两个queue。此时Consumer有3台,我们可以大致的认为每个Consumer负责2个MessageQueue的消费。但是这里有一个原则,那就是一个MessageQueue只能被一台Consumer消费,而一台Consumer可以消费多个MessageQueue。
为什么?道理很简单,RocketMQ支持的顺序消费,是指的分区顺序性,也就是在单个MessageQueue中,消息是具有顺序性的,而如果多台Consumer去消费同一个MessageQueue,就很难去保证顺序消费了。
由于有很多个Consumer在消费多个MessageQueue,所以为了不出现数据倾斜,也为了资源的合理分配利用,在Producer发送消息的时候,需要尽可能的将消息均匀的分发给多个MessageQueue。
同时,上面那种一个Consumer消费了2个MessageQueue的情况,万一这台Consumer挂了呢?这两个MessageQueue不就没人消费了?
以上两种情况分别是Producer端的负载均衡、Consumer端的负载均衡。
Producer端负载均衡
关于Producer端上面的负载均衡,上面的流程图已经给了出来,并且给出了源码的验证。首先是容错策略,会去避开一段时间有问题的Broker,并且加上如果选择了上次的Broker,就会重新进行选择。
Consumer端负载均衡
首先Consumer端的负责均衡可以由两个对象触发:
1)Broker
2)Consumer自身
Consumer 也会向所有的 Broker 发送心跳,将消息的消费组名称、订阅关系集合、消息的通信模式和客户端的ID等等。Broker 收到了 Consumer 的心跳之后,会将其存在 Broker 维护的一个 Manager 中,名字叫 ConsumerManager。当Broker监听到了 Consumer 数量发生了变动,就会通知 Consume r进行 Rebalance。
但是如果 Broker 通知 Consumer 进行 Rebalance 的消息丢了呢?这也就是为什么需要第Consumer 自身进行触发的原因。Consumer会在启动的时候启动定时任务,周期性的执行rebalance操作。

默认是20秒执行一次。具体的代码如下。

具体流程
首先,Consumer的Rebalance会获取到本地缓存的Topic的全部数据,然后向Broker发起请求,拉取该Topic和ConsumerGroup下的所有的消费者信息。此处的Broker数据来源就是Consumer之前的心跳发送过去的数据。然后会对Topic中MessageQueue和消费者ID进行排序,然后用消息队列默认分配算法来进行分配,这里的默认分配策略是平均分配。

首先会均匀的按照类似分页的思想,将MessageQueue分配给Consumer,如果分配的不均匀,则会依次的将剩下的MessageQueue按照排序的顺序,从上往下的分配。所以在这里Consumer 1被分配到了4个MessageQueue,而Consumer 2被分配到了3个MessageQueue。
Rebalance完了之后,会将结果和Consumer缓存的数据做对比,移除不在ReBalance结果中的MessageQueue,将原本没有的MessageQueue给新增到缓存中。
触发时机
1)Consumer启动时 启动之后会立马进行Rebalance
2)Consumer运行中 运行中会监听Broker发送过来的Rebalance消息,以及Consumer自身的定时任务触发的Rebalance
3)Consumer停止运行 停止时没有直接的调用Rebalance,而是会通知Broker自己下线了,然后Broker会通知其余的Consumer进行Rebalance。
换一个角度来分析,其实就是两个方面,一个是队列信息发生了变化,另一种是消费者发生了变化。
源码验证
然后给出核心的代码验证,获取数据的逻辑如下

验证了我们刚刚说的获取了本地的Topic数据缓存,和从Broker端拉取所有的ConsumerID。
接下来是验证刚说的排序逻辑。

接下来是看判断结果是否发生了变化的源码。


可以看到,Consumer通知Broker策略,其本质上就是发送心跳,将更新后的数据通过心跳发送给所有的Broker。
延迟消息
延迟消息功能
延迟消息的核心使用方法就是在 Message 中设定一个 MessageDelayLevel 参数,对应 18 个延迟级别。然后 Broker 中会创建一个默认的 Schedule_Topic 主题,这个主题下有 18 个队列,对应 18 个延迟级别。消息发过来之后,会把消息存入 Schedule_Topic 主题中对应的队列。然后等延迟消息到了,再转发到目标队列,推送给消费者进行消费。
| |
延迟消息整体流程

延迟消息的处理入口在 scheduleMessageService 这个组件中,它会在 broker 启动时也一起加载。
延迟消息写入
代码见 CommitLog.putMessage 方法
在 CommitLog 写入消息时,会判断消息的延迟级别,然后修改 Message 的 Topic 和 Queue,达到转储 Message 的目的。
延迟消息转储到目标 Topic
核心服务是 ScheduleMessageService,也是 Broker 启动过程中的一个功能组件。
然后 ScheduleMessageService 每隔 1 秒钟执行一个 executeOnTimeup 任务,将消息从延迟队列中写入正常 Topic 中。代码见 ScheduleMessageService 中的 DeliverDelayedMessageTimerTask.executeOnTimeup 方法。
其中有个需要注意的点事 ScheduleMessageService 的 start 方法中,有一个很关键的 CAS 操作:
| |
保证同一时间只会有一个 DeliveDelayedMessageTimerTask 执行。保证了消息安全的同时业限制了消息进行回传的效率。这也是很多互联网公司在使用 RocketMQ 时,对源码进行定制的一个重点。
14 - 搜索
Introduction
ES搜索等
14.1 - ES和Solr比较
Lucene
Lucene是用Java写的,早期被发布在Doug Cutting的个人网站和SourceForge(一个开源软件网站)。2001年底Lucene成为Apache软件基金会jakarta项目的一个子项目。
随着每个版本的发布,这个项目得到明显的增强,也吸引了更多的用户和开发人员。2004年7月,Lucene1.4版正式发布,10月的1.4.2版本做了一次bug修正。
ES
概述
ElasticSearch简称ES,是基于ApacheLucene构建的开源搜索引擎,是当前最流行的企业级搜索引擎。Lucene本身就可以被认为迄今为止性能最好的一款开源搜索引挂工具包,但是lucene的API相对复杂,需要深厚的搜索理论。很难集成到实际的应用中去。
ES是采用java语言编写,提供了简单易用的RestFulAPI,开发者可以使用其简单的RestFul API,开发相关的搜索功能,从而遵免1ucene的复杂性。
诞生
多年前,一个叫做ShayBanon的刚结婚不久的失业开发者,由于妻子要去伦敦学习厨
师,他便跟着也去了。在他找工作的过程中,为了给妻子构建一个食谱的搜索引擎,他开始
构建一个早期版本的Lucene。
直接基于Lucene工作会比较困难,所以Shay开始抽象Lucene代码以便Java程序员可以在
应用中添加搜索功能。他发布了他的符一个开源项目,叫做“Compass”。
后来Shay找到一份工作,这份工作处在高性能和内存数据网格的分布式环境中,因此高性能
的、实时的、分布式的搜索引擎也是理所当然需要的。然后他决定重写Compass库使其成为一
个独立的服务叫做Elasticsearch。
第一个公开版本出现在2916年2月,在那之后Elasticsearch已经成为Github上最受欢迎的项目
之一,代码贡献者超过388人。一家主营Elasticsearch的公司就此成立,他们一边提供
商业支持一边开发新功能,不过Elasticsearch将永远开源且对所有人可用。
Shay的妻子依旧等待着她的食谱搜索…
目前国内大厂几乎无一不用Elasticsearch,阿里,腾讯,京东,美团等等…
ElasticSearch VS Solr
1、ES基本是开箱即用,非常简单。Solr安装略微复杂一丢丢!
2、Solr利用Zookeeper进行分布式管理,而Elasticsearch自身带有分布式协调管理功能.
3、Solr支持更多格式的数据,比奶SON、XML、CSV,而Elasticsearch仅支持json文件格式。
4、Solr官方提供的功能更多,而Elasticsearch本身更注重于核心功能,高级功能多有第三方插件提供,例如图形化界面需要kibana友好支撑
5、Solr查询快,但更新索引时慢(即插入删除慢),用于电商等查询多的应用;
1)ES建立索引快(即查询慢),即实时性查询快,用于facebook新浪等搜索。
2)Solr是传统搜索应用的有力解决方案,但Elasticsearch更适用于新兴的实时搜索应用。
6、Solr比较成熟,有一个更大,更成熟的用户、开发和贡献者社区,而Elasticsearch相对开发维护者较少,更新太快,学习使用成本较高。
14.2 - ES安装与使用
安装ES
传统方式安装
1、下载并解压ES安装包
| |
2、创建一个用户,用于ES启动 因为ES在5.x版本后,强制在Linux中不能使用root用户启动ES进程。
| |
3、启动ES
| |
4、测试启动 ES默认不支持跨域访问,在不修改配置的情况下只能在本机上访问测试是否成功
| |
或
| |
开启远程访问
ES默认不支持远程访问,开启步骤如下:
| |
解决错误

这里特别提醒,如果开启远程访问后启动ES,就会出现上面安装提到的Linux的各种限制报错,看报错信息一一解决即可。
1、解决错误-1
修改Linux系统的限制配置,将文件创建数修改为65536个。
1)修改系统中允许应用最多创建多少文件等的限制权限。Linux默认一般限制创建的文件数为65535个。但是ES至少需要65536的文件创建数的权限。
2)修改系统中允许用户启动的进程开启多少个线程。Linux默认root用户可以开启任意数量的线程,其他用户的进程可以开启1024个线程。必须修改限制数为4096+。因为ES至少需要4096个线程池预备。
| |
2、解决错误-2
| |
3、解决错误-3 修改系统控制权限,ES需要开辟一个65536字节以上空间的虚拟缓存。Linux不允许任何用户和应用程序直接开辟这么的虚拟内存。
| |
4、解决错误-4 使用一个节点初始化集群
| |
Docker方式安装
docker方式安装没有传统方式安装那么繁琐
1、获取镜像
| |
2、运行ES
| |
3、访问ES
| |
Kibana
概述
可以是用postman或者apipost通过REST API去操作ES,但是没有任何语法提示。
| |
ES官方推荐是用Kibana。
简介
Kibana是一个针对ElasticSearch的开源分析及可视化平台,使用Kibana可以查询、查看并与存储在ES索引中的数据进行交互操作,使用Kibana能执行高级的数据分析,并能以图表、表格和地图的形式查看数据。
安装
传统方式安装
1、下载Kibana
| |
注意下载的版本号要和 ES 版本号完全对应,避免出现问题 2、安装
| |
3、编辑Kibana配置文件 Kibana默认也不允许远程连接
| |
4、启动Kibana
| |
5、访问Kibana的web界面
| |
6、侧边栏 Management-> Dev Tools
| |
Docker安装
1、获取镜像
注意版本与ES保持一致
| |
2、运行kibana
| |
3、进入容器连接到ES,重启kibana容器
| |
问题,每次都要在容器启动后进入容器修改配置,然后重启容器,非常麻烦!
数据卷方式启动
1、拷贝配置文件
| |
2、同上修改配置文件 3、启动容器
| |
docker-compose一键启动
docker-compose.yml 配置文件
Expand/Collapse Code Block
| |
kibana.yml配置文件
| |
注意这里elasticsearch的ip是直接使用docker-compose.yml中定义的服务名称的,因为它们处在同一个网络中,所以可以这么使用,好处是ip如果变更了无需修改配置文件
启动与关闭
| |
集群
一个集群就是由一个或多个节点组织在一起。一个集群有一个唯一的名字标识,默认是 elasticsearch,这个名字标识非常重要,一个节点只能通过制定某个集群的名字加入集群。
基本概念
节点(node)
索引(Index)
映射(Mapping)
文档(Document)
分片(shards)
复制(replicas)
搭建集群
规划
1、准备3个ES节点
| |
注意事项: 1、所有节点集群名称必须一致 cluster.name
2、每个节点必须有一个唯一的名字 node.name
3、开启每个节点远程连接 network.host: 0.0.0.0
4、指定使用IP地址(发布地址)进行集群节点通信 network.publish_host
5、修改web端口tcp端口:http.port transport.tcp.port
6、指定集群中所有节点通信列表 discovery.seed_hosts: node-1 node-2 node-3 相同
7、允许集群初始化 master 节点节点数:cluster.initial_master_nodes: ["node-1", "node-2", "node-3"]
8、集群最少几个节点可用 gateway.recover_after_nodes: 2
9、开启每个节点跨域访问 http.cors.enabled: true http.cors.allow-origin: "*"
配置文件
config/elaticsearch.yml文件,每个节点都需要配置,修改下某些参数
Expand/Collapse Code Block
| |
docker-compose.yml
(参考该文件配置相应路径及文件内容)
Expand/Collapse Code Block
| |
验证集群
1、访问localhost:9201/9202/9203,查看"cluster_uuid"字段应该是一致的。
2、查看集群状态
访问任何一个节点的集群信息
| |
Head插件
(非官方)用来直观的查看集群的状态
安装步骤:
1、github上搜索 elasticsearch-head 插件,并下载
| |
2、安装node.js
| |
3、解压缩node.js
| |
4、配置环境变量 略
5、启动Head插件
| |
6、访问Head插件,默认端口9100
| |
14.3 - ES基本操作
索引基本操作
注意:
1、索引创建之后不能被修改,只能被删除
2、索引名只能为小写字母
索引有3中状态:红、绿、黄
查看索引
| |
pri:集群主分片 rep:集群副本分片
docs.count:索引下文档数
docs.deleted:代表删除
store.size:表示存储大小
pri.store.size:表示主分片存储的大小或主索引存储的大小
查询索引详细信息
| |
其中简写标题对应完整名称,也可以直接使用完整标题替代 h: health
s: status
i: index
id: id
p: pri
r: rep
dc: docs.count
dd: docs.deleted
ss: store.size
cds: creation.date.string
创建索引
Expand/Collapse Code Block
| |
删除索引
| |
映射基本操作
基本类型
字符串类型:keyword/text
数字类型:integer/long
小数类型:float/double
布尔类型:boolean
日期类型:date
创建索引&映射
Expand/Collapse Code Block
| |
查看映射
查看某个索引的映射信息
| |
注意: keyword/integer/long/double/date/boolean/ip 不分词
text类型 默认es标准分词器(StandardAnalyzer),中文单字分词,英文单词分词
文档基本操作
添加文档
| |
查询文档
| |
删除文档
| |
更新文档
| |
文档批量操作
注意:
1、批量操作中,某个操作失败不会影响其他操作,而是继续执行,返回时按照执行的状态返回,即每一条独立执行且返回各自结果
2、语法规定数据不能换行
1、批量添加文档
| |
2、添加、更新、删除
| |
文档数量查询
| |
DSL查询
概述
ES中提供了一种强大的检索数据方式,称之为Query DSL(Domain Specified Language),Query DSL是利用Rest API传递JSON格式的请求体(Request Body)数据与ES进行交互,这种方式的丰富查询语法让ES检索变得更强大、更简洁。
语法格式
| |
返回值
took
查询时间(请求发出到返回时间),单位毫秒
timed_out
是否超时
_shards
当前索引的分片信息
hits
表示查询结果
total
value表示符合条件的总记录数
max_score
搜索文档的最大得分
hits
结果数据集
_
_type:类型,如_doc
_id:文档id
_score:文档得分
_source:文档源数据
查询条件
查询所有[match_all]
match_all - 返回索引中的全部文档
| |
关键词查询[term]
term - 用来使用关键词查询
这里可以结合_mapping查询字段类型
Expand/Collapse Code Block
| |
范围查询[range]
range - 用来指定查询范围内的文档
gt表示大于,lt表示小于,e表示等于
| |
前缀查询[prefix]
prefix - 用来检索含有指定前缀的关键词的相关文档
| |
通配符查询[wildcard]
wildcard - 通配符查询,? 用来匹配一个任意字符,* 用来匹配多个任意字符
| |
多id查询[ids]
ids - 值为数组类型,用来根据一组id获取多个对应的文档
| |
模糊查询[fuzzy]
fuzzy - 用来模糊查询含有指定关键字的文档
| |
注意: 模糊查询最大模糊错误必须在0-2之间
- 搜索关键词长度为2不允许存在模糊
- 搜索关键词长度为3-5允许一次模糊
- 搜索关键词长度大于5允许最大2模糊
布尔查询[bool]
bool - 用来组合多个条件实现复杂查询
must:相当于 && 同时成立
should:相当于 || 成立一个就行
must_not:相当于 ! 不能满足任何一个
Expand/Collapse Code Block
| |
多字段查询[multi_match]
| |
注意:字段类型分词,将查询条件分词之后进行查询该字段,字段不分词则会将查询条件作为整体查询
默认字段分词查询[query_string]
| |
注意:查询字段分词就将查询条件分词查询,查询字段不分词将查询条件不分词查询
高亮查询[highlight]
highlight - 让符合条件的文档中的关键词高亮(有点类似 grep 的 color)
只有可分词的字段才会高亮,"*"表示所有字段高亮
| |
查询结果中,hits数组中会多个"highlight"字段,默认高亮关键会使用修饰(表示斜体)。 如果觉得这种修饰方法不太直接,可以将结果"highlight"内容保存至html文件,然后用浏览器打开。
| |
或者指定标签:pre_tags和post_tags,内容如上查询demo
返回指定条数[size]
size - 指定查询中的返回条数,默认返回值10条
| |
分页查询[from]
from - 用来指定其实返回位置,和size关键字连用可实现分页效果
| |
指定字段排序[sort]
| |
返回指定字段[_source]
_source - 在数组中指定返回字段
| |
过滤查询
概述
过滤查询(filter query),准确来说,ES中的查询操作分为2种:查询(query)和过滤(filter)。
查询即是上文提到的query查询,默认会计算每个返回文档的得分排序。
过滤(filter)只会筛选出符合条件的文档,并不计算得分,而且它可以缓存文档。
单从性能上分析,过滤比查询块。过滤适合在大范围筛选数据,而查询适合精确匹配数据。一般应用时应先使用过滤操作过滤数据,然后查询匹配数据。
filter使用
先执行filter,再执行query。
ES会自动缓存经常使用的过滤器,以加快性能。
filter需要配置bool查询
| |
常见的过滤类型有:term、terms、range、exists、ids等
terms
| |
post_filter
待补充
| |
聚合查询
聚合查询(Aggregation Aggs),是ES除搜索功能外提供的针对ES数据做统计分析的功能。基于查询条件来对数据进行分桶、计算的方法。类似于SQL中的 group by 等一些操作。
分组
根据某个字段进行分组,统计数量
| |
最大值
| |
最小值
| |
平均值
| |
求和
| |
14.4 - ES基本操作-分词器
索引原理
倒排索引
倒排索引也叫反向索引,即根据value找key。正向索引则是通过key找value
索引区
根据字段类型存储,如是否分词等
元数据区
记录原始数据
分词器
Analysis和Analyzer
Analysis:文本分析是把全文本转换一系列单词(term/token)的过程,也叫分词(Analyzer)。Analysis是通过Analyzer来实现的。分词就是将文档通过Analyzer分成一个一个的Term(关键词查询),每一个Term都指向包含这个Term的文档
Analyzer组成
分词器(analyzer)都是由三种构件组成:character filters, tokenizers, token filters
注意:
1、这三种构件顺序是固定的
2、字符过滤器有0个或多个;token过滤器有0个或多个
1、character filters
字符过滤器:在一段文本进行分词之前,先进行预处理,比如常见的过滤html标签(hello -> hello),& -> and (I&you -> I and you)等
2、tokenizers
分词器:英文分词可以根据空格将单词分开,中文分词比较复杂。
在ES中默认使用标准分词器:StandardAnalyzer。其特点是中文单字分词,英文单词分词,英文统一转小写,过滤标点符号。
3、token filters
Token过滤器:将切分的单词进行加工。大小写转换,去掉停用词(具体可以自行搜索,像a/and/the等),加入同义词等。
内置分词器及测试
Standard Analyzer
默认分词器,英文按单词切分,并小写处理
| |
Simple Analyzer
按照单词切分(符号被过滤),小写处理,空格分词,中文不分词
| |
Stop Analyzer
小写处理,停用词过滤
Whitespace Analyzer
按照空格切分,不转小写,不去掉标点符号
| |
Keyword Analyzer
不分词,直接将输入当输出
| |
创建索引设置分词
| |
中文分词器
在ES中支持中文分词器有smartCN、IK等,推荐使用IK分词器
IK安装
IK分词器并不是官方提供的,需要自动到github下载,地址为:https://github.com/medcl/elasticsearch-analysis-ik
注意:
1、IK的版本与ES版本一致
2、Docker容器运行ES安装插件目录为
| |
安装步骤: 1、下载对应版本
| |
2、解压
| |
3、移动到ES安装目录的plugins目录中
| |
如果是docker-compose,则将修改
| |
IK使用
IK有两种粒度的拆分
1、ik_smart
会做最粗粒度的拆分
| |
2、ik_max_word 会将文本做最细粒度的拆分
| |
扩展词、停用词配置
IK分词器config目录下已经定义了一下扩展词如extra_main.dic、和停用词如extra_stopword等。我们可以基于这些词典增加我们的相应词汇。
IK支持自定义扩展词典和停用词典
定义扩展词典和停用词典可以修改IK分词器config目录中 IKAnalyzer.cfg.xml 这个文件
注意,词典的编码一定要为UTF-8才能生效
1、修改 IKAnalyzer.cfg.xml
| |
2、在IK分词器config目录下创建 ext_dict.dic 文件
| |
3、在IK分词器config目录下创建 ext_stopword.dic 文件
| |
4、重启ES
14.5 - ES基本操作整合SpringBoot
概述
Spring Data Elasticsearch
参考spring官方文档:https://spring.io/projects/spring-data-elasticsearch
引入ES
引入依赖
| |
实际是利用spring-data去操作elasticsearch
自定义ES配置
application.properites
| |
配置客户端
这里tcp端口9300除了与业务交互,还可以是集群之间心跳检测等。
| |
操作ES
客户端对象
ElasticsearchOperations
虽然没有显式创建,但是spring data确实创建了这个对象。
始终以面向对象的方式去操作ES。
RestHighLevelClient(推荐)
通过REST方式去操作,和Kibana操作一致。
ElasticsearchOperations
优点:操作简单
缺点:耦合性强,复杂操作可能更复杂
相关注解
@Document:将这个类对象转换为 ES 中一条文档进行录入
indexName:创建索引的名称
createIndex:是否创建索引
| |
操作
Expand/Collapse Code Block
| |
注意:通过这种方式,然后去查询数据时,在返回的hits结果数据集中,_source字段也就文档源数据中会增加一个字段 _class用来记录反序列化的全类名
RestHighLevelClient
创建索引映射
接上文写测试方法,测试类同上类似
Expand/Collapse Code Block
| |
对象写入读取
测试类代码同上
Expand/Collapse Code Block
| |
聚合查询
测试类同上
| |
14.6 - 深入理解ElasticSearch
Introduction
深入理解ElasticSearch
14.6.1 - 深入理解ES-01简介
Apache Lucene简介
熟悉Lucene
ES的创始人使用 Apache Lucene 而不是从头开发全文检索库?可能是因为 Lucene 的以下特点:成熟、高性能、可扩展、轻量级以及强大的功能。
Lucene 内核可以创建为独立的 Java 库文件并且不依赖第三方代码,用户可以使用它提供的各种所见即所得的全文检索功能进行索引和搜索操作。
Lucene的许多扩展提供了各种各样的功能,如多语言处理、拼写检查、高亮显示等。
Lucene的总体架构
Lucene 的架构:
文档(document):索引和搜索的主要数据载体,它包含一个或多个字段,存放将要写入索引或将从索引搜索出来的数据。
字段(field):文档的一个片段,它包括两个部分:字段的名称和内容。
词项(term):搜索时的一个单位,代表文本中的某个词。
词条(token):词项在字段中的一次出现,包括词项的文本、开始和结束的位移以及类型。
Lucene 将写入索引的所有信息组织成一种名为倒排索引(inverted index)的结构。该结构是一种将词项映射到文档的数据结构,其工作方式与传统的关系数据库不同,可以认为倒排索引时面向词项而不是面向文档的。
索引中不仅保存词项和在文档中出现的次数,还会存储其他信息,如词向量(为单个字段创建的小索引,存储该字段中所有的词条)、各字段的原始信息、文档删除标记等。
每个索引由多个段(segment)组成,每个段只会被创建一次但会被查询多次。索引期间,段经创建就不会再被修改。例如文档被删除后,删除信息被单独保存在一个文件中,而段本身并没有修改。
多个段汇在一起叫做段合并(segments merge),要么强制执行要么由 Lucene 的内在机制决定在某个时刻执行,合并后段的数量更少但更大。段合并非常消耗IO且会清除掉不再使用的信息,如被删除的文档。对于容纳相同数据的索引,段的数量较少时,搜索速度更快。
分析你的数据
查询串转换为用于搜索的词项的过程称为分析(analysis)
文本分析由分析器来执行,而分析器由分词器(tokenizer)、过滤器(filter)和字符映射器组成(character mapper)。
分词器将文本切割成词条,其中携带各种额外信息的词项,包括:词项在原始文本中的位置、词项的长度。分词器的工作成果成为词条流,因为这些词条一个一个推送给过滤器处理。
过滤器的数额可选(0个、1个或多个),用于处理词条流中的词条。它可以移除、修改甚至创造新的词条。过滤器的一些例子:
- 小写过滤器
- ASCII 过滤器:移除词条中所有非 ASCII 字符
- 同义词过滤器
- 多语言词干还原过滤器:词干还原,将词条的文本部分规约到其词根形式 索引与查询
不同的字段可以不同的分析器,如 title 和 description。
检索时如果使用了某个查询分析器(query parser),可以进行查询分析也可以不。如前缀查询(prefix query)不会被分析,而匹配查询(match query)会被分析。
Lucene查询语言
ES 提供的一些查询类型(query type)支持 Lucene 的查询解析语法。
理解基本概念
Lucene 中一个查询通常被分割为词项与操作符,词项可以是单个词也可以是一个短语。
查询汇总也可以包含布尔操作符,用于连接多个词项,使之构成从句(clause)。有以下布尔操作符:
- AND
- OR
- NOT
- +:表示只有包含 + 操作符后面词项的文档才会被认为是与从句匹配
- -:表示与从句匹配的文档不能出现 - 操作符后的词项 当然还可以使用圆括号对从句进行分组,以构造更复杂的从句。
在字段中查询
Lucene 中所有数据都存储在字段(field)中,而字段又是文档的组成单位。对某个字段查询的语法:字段名称加上冒号以及将要在该字段中执行查询的从句。如:
title 字段中包含词项 elasticsearch 的文档:
title: elasticsearch
title 字段中同时包含词项 elasticsearch 和短语 mastering book 的文档:
title: (elasticsearch +"mastering book")
当然也可以写成下面这种形式:
+tile:ealsticsearch +titile:"mastering book"
词项修饰符
除了使用简单词项和从句的常规字段查询意外,Lucene 还允许用户使用修饰符(modifier)修改传入查询对象的词项。最常见的修饰符就是通配符(wildcard)。Lucene 支持两种通配符:? 和 *。(匹配任意一个字符 和 匹配多个字符)。
注意:处于对性能的考虑,通配符不能作为词项的第一个字符出现
除通配符之外,Lucene 还支持模糊(fuzzy and proximity)查询,通过使用 ~ 字符以及一个紧随其后的整数值(表示近似词项与原始词项的最大编辑距离)。如 writer~2 包含匹配词项 writer 和 writers 的文档。
当修饰符 ~ 用于短语时,也是同理。如 title:"mastering elasticsearch"~2,可以包含"mastering elasticsearch" 和 "mastering book elasticsearch"。
使用 ^ 字符并赋以一个浮点数对词项加权(boosting),以提高该词项的重要程度。
使用方括号和花括号来构建范围查询。如:price: [10.00 TO 15.00] 或 name: [Adam To Adria]。使用花括号则表示排除边界。
特殊字符处理
需要搜索某个特殊字符(包含+、-、&&、||、!、(、)、{}、[]、^、"、~、*、?、:、\、/),先试用反斜杠对这些特殊字符进行转义。
ElasticSearch简介
ElasticSearch 是一个可用于构建搜索应用的成品软件。它最早是由 Shay Banon 创建并与2010年2月发布。之后的几年 ElasticSearch 迅速流行开来,成为商业解决方案之外且开源的一个重要选择,也是下载量最多的开源软件之一,每月下载量超过20万次。
基本概念
索引
数据存储在一个或多个索引(index)中,类似 SQL 领域术语的数据库。
注意:ElasticSearch 中的索引可能有一个或多个 Lucene 索引构成,具体细节有 ElasticSearch 的索引分片(shard)、复制(replica)机制及其配置决定。
文档
文档(document)是 ElasticSearch 中的重要实体(对 Luecene 来说也是如此)。文档由字段构成,每个字段有它的字段名以及一个或多个字段值(这种情况该字段被称为多值的,即文档中有多个同名字段)。文档之间可能有各自不同的字段集合,且文档并没有固定的模式或强制的结构(这些规则也适用于 Lucene 文档)。从客户端的角度来看文档是一个 JSON 对象。
映射
所有文档在写入索引前都需要先进行分析。用户可以设置一些参数,来决定如何将输入文本分割成词条。ES 也提供了各种特性,如排序时所需的字段内容信息。这就是映射(mapping)扮演的角色,存储所有这种元信息。虽然 ES 能根据字段值自动检测字段的类型,但为了避免一些不可预期的意外,还是用户自行配置映射。
类型
每个文档都有与之对应的类型(type)定义。这允许用户在一个索引中存储多种文档类型,并为不同文档类型提供不同的映射。
节点
单个 ES 服务实例成为节点(node)。
集群
当数据量或查询压力超过单机负载时,需要多个节点来协同处理,所有这些节点组成的系统称为集群(cluster)。集群也是无间断提供服务的一种解决方案,即便在某些节点因为宕机或执行管理任务(如升级)不可用时。ES 几乎无缝集成了集群功能。
分片
集群允许系统存储的数据总量超过单机容量,为了满足这个需求,ES 将数据散步到多个物理 Lucene 索引上。这些 Lucene 索引称为分片(shard),而散步这些分片的过程叫做分片处理(sharding)。ES 会自动完成分片处理,并且让这些分片呈现出一个大索引的样子。
ES 本身自动进行分片处理外,用户为具体的应用进行参数调优也是直观重要的,因为分片的数量在索引创建时就已经配置好,而且之后无法改变,至少目前的版本是这样的。
副本
分片处理允许用户向 ES 集群推送超过单机容量的数据。副本(replica)则解决了访问压力过大时单机无法处理所有请求的问题。即wield每个分片创建冗余的副本,处理查询时可以把这些副本用作最初的主分片(primary shard)。节点宕机 ES 可以使用其副本,不会造成数据丢失,且支持任意时间点添加或移除副本。
网关
ES 工作过程中,集群状态、索引设置的各种信息都会被收集起来,并在网关(gateway)中被持久化。
架构背后关键概念
从架构的角度出发,ES 具有下面这些主要特征:
- 合理的默认配置,使得用户简单安装后能直接使用而不需要任何额外的调试,包括内置的发现(如字段类型检测)和自动配置功能。
- 默认的分布式工作模式。每个节点总是假定自己是某个集群的一部分或将是某个集群的一部分,一旦工作启动节点便会加入某个集群。
- 对等架构(P2P)可以避免单点故障(SPOF)。节点会自动连接到集群中的其他节点,进行相互的数据交换和监控操作。包括索引分片的自动复制。
- 易于向集群扩充新节点,不论是从数据容量的角度还是数量角度。
- 没有对索引中的数据结构强加任何限制,从而允许用户调整现有的数据模型。ES 支持在一个索引中存在多种数据类型,并允许用户调整业务模型,包括处理文档之间的关联(尽管这种功能非常有限)。
- 准实时(Near Real Time,NRT)搜索和版本同步(versioning)。因为分布式特性,查询延迟和节点之间临时的数据不同步是难以避免的。ES 尝试消除这些问题并且提供额外的机制用于版本同步。
工作流程
启动过程
节点启动时,使用广播技术(也可配置为单播)发现统一集群中的其它节点(配置集群名称)并与之连接。

集群中会有一个节点被选为管理节点(master node)。该节点负责集群的状态管理以及在集群拓扑变化时做出反应,分发索引分片至集群的相应节点上。
从用户角度来看,管理节点并不比其它节点重要,这与其它某些分布式系统不同(如数据库)。实际上,不需要知道哪些是管理节点,所有操作可以发送至任意接地那,ES 内部会自行处理。(ES 是基于对等架构的)。
管理节点读取集群的状态信息,并在必要时进行恢复处理。该阶段管理节点会检查所有索引分片并决定哪些分片将用于主分片。然后集群进入黄色状态。
这意味集群可以执行查询,但系统的吞吐量以及各种可能的状态未知(可以简单理解为主分片分配出去,副本没有),接下来寻找冗余的分片并作用副本。若某个主分片的副本数过少,管理节点将决定基于某个主分片创建分片和副本。一切顺利集群将进入绿色状态(即所有主分片和副本均已分配好)。
故障检测
集群正常工作时,管理节点会监控所有可用节点检查是否正在工作。如果任何节点在预定义的超时时间内没有响应,则认为该节点已断开,然后开始启动错误处理过程。即在集群 - 分片之间重新做平衡。

与ES通信
ES 对外公开了一个设计精巧的API(基于REST),并在实践中能轻松整合到任何支持HTTP协议的系统中去。
ES 内部也使用 Java API 进行节点间通信。
索引数据
ES 提供四种方式来创建索引。最简单的是使用索引API,允许用户发送一个文档至特定的索引。如使用 curl 工具(详见 http://curl.haxx.se/ ):
| |
第二、三种方式允许用户通过 bulk API 或 UDP bulk API 一次性发送多个文档至集群。两者的区别在于网络连接方式(HTTP协议、UDP协议)。 第四种方式是使用插件发送数据,称为河流(river),河流运行在ES节点上,能够从外部系统获取数据。
建所以操作只会发生在主分片上,而不是副本上。当一个索引请求发送至某节点时,如果该节点没有对应的主分片或者只有副本,那么会被转发到拥有正确的主分片的节点上。如下图所示:
查询数据
查询 API 占据了 ES API 的大部分内容。使用 DSL(基于JSON的可用于构建复杂查询的语言):
- 使用各种查询类型,包括:简单的词项查询、短语查询、范围查询、布尔查询、模糊查询、区间查询、通配符查询、空间查询等。
- 组合简单查询构建复杂查询。
- 文档过滤,不影响评分的前提下抛弃不满足特定查询条件的文档。
- 查询与特定文档想死的文档。
- 查询特定短语的查询建议和拼写检查。
- 使用切面构建动态导航和计算各种统计量。
- 使用预搜索(prospective search)并查找与指定文件匹配的query集合。 查询分为两个阶段:分散阶段(scatter phase)和合并阶段(gather phase)。分散阶段将 query 分发到包含相关文档的多个分片中执行查询,合并阶段则从众多分片中收集返回结果,然后进行合并、排序、后续处理,再返回给客户端。

索引配置
ES 提供了自动索引配置以及发现识别文档字段类型和结构的功能。也提供了一些功能使得用户能手动配置,例如通过映射配置自定义的文档结构,或设置索引的分片和副本数,抑或定制文本分析过程等。
系统管理和监控
ES 中系统管理和监控相关的 API 允许用户改变集群的设置,如调节集群发现机制和索引放置策略等。且提供集群状态信息后每个节点、索引的统计信息。
小结
本章介绍了 Apache Lucene 的一般架构,如工作原理、文本分析过程以及使用查询语言。此外还介绍了 ElasticSearch 的一些基本概念,如基本架构和内部通讯机制。
14.6.2 - 深入理解ES-02查询DSL进阶
本章涵盖内容:
- Lucene默认评分公式如何工作
- 什么是查询重写
- 查询二次评分如何工作
- 单次请求中实现批量准实时读取操作
- 单次请求中发送多个查询
- 对包括嵌套文档和多值字段的数据排序
- 更新已索引的文档
- 使用过滤器来优化查询
- 在切面计算机制中使用过滤器和作用域
Lucene默认评分公式
查询相关性重点是理解文档对查询的得分是如何计算出来的。文档得分是一个刻画文档与查询匹配程度的参数。Lucene 的默认评分机制:TF/IDF(词频/逆文档频率)算法以及其如何影响文档召回。
文档被匹配
每个被查询返回的文档都有一个得分,得分越高,文档相关度更高。
注意:同一个文档针对不同查询的得分是不同的,比较某文档在不同查询中的得分是没有意义的,不同查询返回文档中的最高得分也不具备可比较性。因为文档得分依赖多个因子,除了权重和查询本身的结构,还包括匹配的词项数目,词项所在字段,以及查询规范化的匹配类型等。
计算文档得分考虑以下因子:
- 文档权重(document boost):索引期赋予某个文档的权重值
- 字段权重(field boost):查询期赋予某个字段的权重值
- 协调因子(coord):基于文档中词项命中个数的协调因子,一个文档命中了查询的词项越多,得分越高。
- 逆文档频率(inverse document frequency):一个基于词项的因子,用来告诉评分公式该词项多么罕见。逆文档频率越低,词项越罕见。评分共识利用该因子为包含罕见词项的文档加权。
- 长度范数(length norm):每个字段的基于词项个数的归一化因子(在索引期计算出来并存储在索引中)。一个字段包含的词项数越多,该因子的权重月底,这意味着 Lucene 评分公式更“喜欢”包含更少词项的字段。
- 词频(term frequency):一个基于词项的因子,用来表示一个词项在某个文档中出现了多少次。词频越高,文档得分越高。
- 查询范数(query norm):一个基于查询的归一化因子,等于查询中词项的权重平方和。查询范数使不同查询的得分能相互比较,尽管这种比较通常是困难的不可行的。
TF/IDF评分公式
Lucene理论评分公式
TF/IDF公式的理论形式如下:

上面的公式糅合了布尔检索模型和向量空间检索模型。
Lucene实际评分公式

得分公式是一个关于查询 q 和文档 d 的函数,有两个因子 coord 和queryNorm 并不直接依赖查询词项,而是与查询词项的一个求和公式想成。
求和公式中每个加数由以下因子连乘所得:词频、逆文档频率、词项权重、范数(长度范数)。从公式可以得出一些基本原则:
- 越多罕见的词项被匹配上,文档得分越高
- 文档字段越短(包含更少的词项),文档得分越高
- 权重越高(不论是索引期还是查询期赋予的权重值),文档得分越高
ES如何看评分
ES 使用了 Lucene 的评分功能但不限于其评分功能(可以替换默认的评分算法),用户可以使用各种不同的查询类型以精确控制文档评分的计算(如 custom_boost_factor 查询、constant_score 查询、custom_score 查询等),还可以使用脚本(scripting)来改变文档得分,还可以使用 ES 0.90 中出现的二次评分功能,通过在返回文档集上执行另一个查询,重新计算前 N 个文档的得分。
更多查询类型可参考:https://lucene.apache.org/core/4_5_0/queries/org/apache/lucene/queries/package-summary.html
查询改写
诸如前缀查询或通配符查询之类的查询类型都是基于多词项的查询,且都涉及查询改写。ES(实际上是 Lucene 执行该操作)使用查询改写是出于对性能的考虑。从 Lucene 的角度查询改写操作就是把费时的原始查询类型实例改写成一个性能更高的查询类型实例。
前缀查询范例
PUT数据

查询数据:
| |
查询结果:

验证 ES 设置 name 字段的映射:
| |
ES 返回结果:
| |
回顾Lucene
Term 这列非常重要,探究 ES 和 Lucene 的内部实现,会发现前缀查询已经改写为下面这种查询:
| |
这意味着前缀查询已经改写为常数得分查询(constant score query),该查询有一个布尔查询构成,而这个布尔查询又由三个词项查询构成。Lucene 做的事就是枚举索引中的词项,并利用这些词项的信息来构建新的查询。比较改写前后两个查询的执行效果,会发现改写后的查询性能有所提升,尤其是当索引中有大量不同词项时。 手动构建改写后的查询:
Expand/Collapse Code Block
| |
查询改写的属性
对任何多词项查询(如前缀和通配符查询)使用rewite来控制查询改写。如:
| |
rewrite 参数配置选项:
- scoring_boolean:将每个生成的词项转化为布尔查询中的一个或从句(should clause)。比较消耗CPU(计算和保存每个词项得分),查询生成的词项太多会超出布尔查询的限制(默认1024个从句,可修改 elasticsearch.yaml 文件的 index.query.bool.max_clause_count 属性,注意会降低查询性能)。
- constant_score_boolean:与 scoring_boolean 类似,但CPU消耗较少,因为该过程不计算每个从句得分,而是每个从句得到一个与查询权重相同的常规得分(默认情况下等于1)。同上也有布尔从句数的限制。
- constant_score_filter:通过顺序遍历每个词项来创建一个私有的过滤器,标记跟每个词项相关的所有文档。命中的文档被赋予一个跟查询权重相同的常量得分。当命中词项数或文档数较大时,该方法比前两种执行速度更快。
- top_terms_N:该选项将每个生成的词项转化为布尔查询中的一个或从句,并保存计算出来的查询得分。与 scoring_boolean 不同在于这里只保留了最佳的前 N 个词项,从而避免超出布尔从句数的限制。
- top_terms_boost_N:与 top_terms_N 类似,不同之处在于该选项产生的从句类型为常量得分查询,得分为从句的权重。
当 rewrite 属性设置为 constant_score_auto 或者根本不设置时,constant_score_filter 或 constant_score_boolean 属性的取值依赖于查询类型及其构造方式。
何时采用何种查询写法?
取决于具体应用场景。接受低精度(往往伴随着高性能),可以采用 top N 查询改写方法。需要更高的查询精度(往往伴随着低性能),应该使用布尔方法。
二次评分
改变查询返回文档的顺序有很多好处,如提升性能(在整个文档集计算文档顺序非常耗时,原始查询的返回文档的子集上做这种计算则非常省事)。二次评分给了用户很多机会定制业务逻辑。
理解二次评分
ES 的二次评分是指重新计算查询返回文档中指定个数文档的得分,意味着 ES 会截取查询返回文档的前 N 个,并使用预定义的二次评分方法来重新计算它们的得分。
范例数据
范例数据保存在 docements.json 文件中,可以使用以下命令添加到索引中:
| |
查询
| |
该查询将返回索引中的所有文档。因为使用了 match_all 查询类型,所以每个返回文档的得分都等于 1.0。
二次评分查询结构
二次评分查询范例:
query.json
| |
这个查询将每个文档的得分改写为该文档的 year 字段中的值。
| |
二次评分参数配置
在 rescore 对象中的查询中,必须配置以下这些参数:
- window_size:窗口大小,该参数默认设置为 form 和 size 参数值之和,指定了每个分片上参与二次评分的文档个数
- query_weight:查询权重值,默认等于1,原始查询的得分与二次评分的得分相加之前将乘以该值
- rescore_query_weight:二次评分查询的权重值,默认等于1,二次评分查询的的饭与原始查询德丰相加之前将乘以该值
- rescore_mode:二次评分的模式,默认为total。ES 0.90.3 引入了该参数。定义了二次评分中文档得分的计算方式,可选项有total(文档得分为原始得分与二次评分得分之和)、max、min、avg和multiply(乘积)。 例如:当 rescore_mode 为 total 时,文档得分计算公式如下:
| |
批量查询
ES 提供了批量操作功能来读取数据和检索,这些操作与批量索引类似,允许用户将多个请求归到一组,尽管每个请求可能有各自的目标索引和类型。
批量取
批量取(MultiGet)可以通过 _mget 端点(endpoint)操作,它允许使用一个请求获取多个文档。
批量查询
批量查询允许用户将多个查询请求打包一组。
排序
默认按文档得分降序排序。可以自定义排序方式。如:
| |
多值字段排序
| |
mode参数可以设置为以下值:
- min:升序排序的默认值,按照字段的最小值进行排序
- max:降序排序的默认值,按照字段的最大值进行排序
- avg:按照该字段的平均值进行排序
- sum:按照字段的总和进行排序
多值geo字段排序
ES 0.92.0RC2 版本提供了基于多维作为数据的排序。
嵌套对象排序
ES 0.90 及以上版本中,可以基于字段中定义的嵌套对象排序。
数据更新API
简单字段更新
使用脚本按条件更新
ES 允许用户结合脚本使用更新 API,如:
| |
使用更新API创建或删除
更新 API 不仅可以用来修改字段,也可以用来操作整个文档。upsert 属性允许用户在当 URL 中地址不存在时创建一个新的文档。如:
| |
过滤器优化查询
ES 允许创建各种不同的查询类型。仅有查询本身不足以满足文档的查询匹配。ES 查询 DSL 提供的大多数查询类型都有它们的相似物,并且能将相似物包装(wrapping)成以下这些查询类型:
- constant_score
- filtered
- custom_filters_score
过滤器与缓存
过滤器是很好的缓存候选方案。ES 提供了过滤器缓存(filter cache),用来存储过滤器的结果。不需要消耗过多的内存,因为只存储了哪些文档能与过滤器相匹配的相关信息。可供后续所有阈值相关的查询重复使用,极大提高了查询性能。
不是所有过滤都默认缓存
因为 ES 中某些过滤器使用了字段数据缓存,可以在基于字段数据的排序时使用,也能在计算切面结果时使用。一下过滤器默认不缓存:
- numeric_range
- script
- geo_bbox
- geo_distance
- geo_distance_range
- geo_ploygon
- geo_shape
- and
- or
- not 上面最后三个本身不适用字段缓存,但它们操作其它过滤器,因此也不缓存。
改变ES的缓存行为
通过设置 _cache 和 _cache_key 属性来开启或关闭过滤器的缓存机制。配置缓存词项过滤器结果,如:
| |
关闭该查询的词项过滤器缓存:
| |
为什么命名cache的key
必要时可以让 ES 自动处理,精细地控制缓存行为就需要手动处理。如已知某些查询很少执行,又想周期性地清除之前查询的缓存。如果不设置 _cache_key,将不得不强制清理全部的过滤器缓存,而如果设置了 _cache_key,那么执行下面命令即可:
| |
何时改变ES过滤器缓存行为
用户比 ES 更清楚自己想要什么。
将数据存放在缓存中会消耗资源,因而在不需要时应及时清理数据。
词项查找过滤器
缓存和各种标准查询并不是 ES 的全部家当。ES 0.90 支持了一个精巧的过滤器,用于给一个具体的查询传递从 ES 取回的多个词项(与 SQL 的 IN 操作符类似)。
将两个查询合并为一个过滤查询(filtered query):
Expand/Collapse Code Block
| |
ES切面机制中的过滤器和作用域
使用切面机制时,需要注意:
- 系统只在查询结果之上计算切面结果。filter对象内部且在query对象外部包含了过滤器,这些过滤器不会对参与切面计算的文档产生影响。
- 作用域(scope)能扩充用于切面计算的文档
切面计算和过滤
ES 的切面在进行计算式并不考虑过滤器的因素。
示例:
Expand/Collapse Code Block
| |
尽管查询被限制为只返回 category 字段值为 book 的文档,但是对切面计算来说不是这样的。切面作用于 books 索引的所有文档(因为使用了 match_all 查询)。
过滤器作为查询一部分
切面计算作用于查询返回结果上,因为过滤器成为了查询的一部分。在上面的案例中,切面计算结果包含两个范围,每个范围只有一个文档。
切面过滤器
如果只想为 title 字段包含词项2的书籍计算分组,可以在查询使用第二个过滤器,虽然能减少查询返回结果,但不够优雅。更明智的做法是使用切面过滤器(facet filter)。
Expand/Collapse Code Block
| |
全局作用域
如果想查询所有书名中包含词项2的文档,但同时又要显示索引中所有文档的基于范围的切面计算结果。这时并不需要强制运行第二个查询,可以使用全局切面作用域(global faceting scope)来达成目的,设置切面类型的 global 属性配置为 true 来实现。
Expand/Collapse Code Block
| |
案例:用户输入查询之后显示一个顶级导航,如使用基于词项的切面计算来枚举所有电子商务网站的顶级目录。这种案例中 global 作用域就显得尤为有用。
小结
本章介绍了 Apache Lucene 的默认评分公式、查询重写过程(query rewrite process)以及它如何工作。还讨论了 ES 的一些功能,例如查询的二次评分(query rescore)、准实时批量获取(multi near real-time get)、批量搜索操作(bulk search operations)。以及如何使用 update API 来部分改变文档、对数据进行排序、如何使用过滤功能(filterring)来改进查询的性能和如何在切面机制中使用过滤器(filters)和作用域(scope)。
14.6.3 - 深入理解ES-03底层索引控制
改变Lucene评分方式
Lucene 4.0 发布后可以改变默认的基于 TF/IDF 的评分算法,因为 Lucene 的 API 做了一些改变,使得用户能轻松地修改和扩展该评分公式。且提供了更多的相似度模型,允许我们采用不同的评分公式。
可用的相似度模型
Lucene 4.0 之前,除了最原始和默认的相似度模型外,TF/IDF 模型也是可用的。在此基础上又新增了3种相似度模型可供使用:
- Okapi BM25 模型:基于概率模型的相似度模型,可用于估算文档与给定查询匹配的概率。模型名字:BM25。一般来说,该模型在短文本文档上的效果最好,因为这种场景中重复词项对文档的总体得分损害较大。
- 随机偏离(Divergence from randomness)模型:基于同名概率模型的相似度模型。模型名字:DFR。一般来说,随机偏离模型在类似自然语言的文本上效果较好。
- 基于信息的(information based)模型:与随机偏离模型类似。模型名字:IB。同样,IB模型也在类似自然语言的文本上拥有较好的效果。
为每字段配置相似度模型
ES 0.90 以后,用户可以在映射中为每字段设置不同的相似度模型。例如:
Expand/Collapse Code Block
| |
如果希望在 name 字段和 contents 字段使用 BM25 相似度模型,可以添加 similarity 字段,并将该字段设置为响应的相似度模型名字。如下:Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"mappings": {
"post": {
"properties": {
"id": {
"type": "long",
"store": "yes",
"precision_step": "0"
},
"name": {
"type": "string",
"store": "yes",
"index": "analyzed",
"similarity": "BM25"
},
"contents": {
"type": "string",
"store": "no",
"index": "analyzed",
"similarity": "BM25"
}
}
}
}
}
以上修改就足够了,并不需要额外配置。
注意:对于随机偏离模型和基于信息的相似度模型,需要一些额外的配置,用于控制这些相似度模型的行文。
相似度模型配置
选择默认相似度模型
提供一个名为 default 的相似度模型的配置信息,需要将配置文件修改为如下形式:
| |
改为名为 base 的相似度模型:
| |
配置被选用的相似度模型
配置TF/IDF相似度模型
只设置一个参数:discount_overlaps 属性,其默认值为true。默认情况下,位置增量(position increment)为0(即该词条的position计数与前一个词条相同)的词条在计算评分时并不会被考虑进去。如果在计算文档时需要考虑这类词条,则需要将相似度模型的 discount_overlaps 属性值设置为 false。
配置Okapi BM25相似度模型
有如下参数可供配置:
- k1:浮点数,控制饱和度(saturation),即词频归一化中的非线性项
- b:浮点数,用于控制文档长度对词频的影响
- discount_overlaps:与TF/IDF相似度模型的discount_overlaps参数作用相同
配置DFR相似度模型
有如下参数可供配置:
- basic_model:可设置为 be、d、g、if、in 和 ine。
- after_effect:可设置为 no、b 和 l。
- normalization:可设置为 no、h1、h2、h3 和 z。 如果 normalization 参数值不是 no,则需要设置归一化因子(依赖于normalization参数值)。参数值为 h1 时,使用 normalization.h1.c 属性;参数值为 h2 时,使用 normalization.h2.c 属性;参数值为 h3 时,使用 normalization.h3.c 属性;参数值为 z 时,使用 normalization.z.z 属性。属性值的数据类型均为浮点型,如下:
| |
配置IB相似度模型
有如下参数可供配置:
- distribution:可设置为 ll 或 spl。
- lambuda:可设置为 df 或 tff。 IB 模型也需要配置归一化因子,配置方式与 DFR 模型相同。如下:
| |
使用编解码器
Lucene 4.0 的一个显著变化是允许用户改变索引文件编码方式。在此之前之前只能通过修改内核代码来实现。它提供了灵活的索引方式,允许用户改变倒排索引的写入方式。
简单使用范例
需要改变索引写入格式的理由之一是性能。某些字段需要特殊处理,如主键,借助一些技术,主键值能很快被搜索到。还可以使用 SimpleTextCode 来调试代码,以便了解写入索引中的数据格式。
编解码器是 Lucene 提供的功能,ES 并没有相应的接口。
工作原理解释
假设 posts 索引有如下映射:
Expand/Collapse Code Block
| |
编解码器需要逐字段配置。为了配置某个字段使用特定的编解码器,需要在字段配置文件中添加 postings_format 属性,并赋值如 plusing。如下:Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"mappings": {
"post": {
"properties": {
"id": {
"type": "long",
"store": "yes",
"precision_step": "0",
"postings_format": "plusing"
},
"name": {
"type": "string",
"store": "yes",
"index": "analyzed"
},
"contents": {
"type": "string",
"store": "no",
"index": "analyzed"
}
}
}
}
}
然后执行以下命令,查看配置是否生效(id字段的编码器类型):
| |
可用的倒排表格式
- defult:当没有显式配置时,倒排表使用该格式。该格式提供了存储字段(stored field)和词项向量压缩功能。
- plusing:该编码器将高基(high cardinality)字段中的倒排表编码为词项数组,会减少 Lucene 搜索文档时的查找操作。使用该编码器,可以提高在高基字段中的搜索速度。
- direc:该编码器在读索引阶段将词项载入词典,且词项在内存汇总为未压缩状态。该编码器能提升常用字段的查询性能。词项和倒排表数组都需要存储在内存中,非常消耗内存,谨慎使用。
- memory:将所有数据写入磁盘,读取时则使用 FST(Finite State Transducers)结构直接将词项和倒排表载入内存。数据都在内存因而能加速常见词项的查询。
- bloom_default:是 default 编解码器的一种扩展,在这基础上加入了 bloom filter 的处理,且 bloom filter 相关数据会写入磁盘中。当读入索引时,bloom filter 相关数据会被读入内存,用于快速判断某个特定值是否存在。
- bloom_pulsing:pulsing 编解码器的扩展,在其基础上又加入 bloom filter 的处理。
配置编解码器
custom_default
Expand/Collapse Code Block
| |
default编解码器属性
- min_block_size:确定了 Lucene 将词项词典(term dictionary)中的多个词项编码为块(block)时,块中的最小词项数。默认值为25。
- max_block_size:同上,块中的最大词项数。默认值为48。
direct编解码器属性
- min_skip_count:确定允许写入跳表(skip list)指针的具有相同前缀的词项的最小数量。默认值为8。
- low_freq_cutoff:编解码器使用单个数组对象来存储文档频率(document frequence)低于该参数值的词项的倒排链及位置信息。默认值为12。
memory 编解码器属性
- pack_fst:布尔乐行,默认设置为false,用来确认保存倒排链的内存结构是否被打包为 FST(Finite State Transducers)类型。而打包为 FST 类型能减少保存数据所需的内存量。
- acceptable_overhead_ratio:浮点型,指定了内部结构的压缩率,默认值为0.2。值为0时,没有额外的内存消耗,但会导致较低的性能。值为0.5时,将会多付出50%的内存消耗,但能提升性能。值超过1也是可行的,但会导致更多的内存开销。
pulsing编解码器属性
除了 default 编解码器的属性外,还有如下:
- freq_cut_off:默认为1,表示文档频率阈值,若词项对应的文档频率小于等于该阈值,则将该词项的倒排链写入词典中。
bloom filter编码器属性
- delegate:用来确定将要被 bloom filter 包装(wrap)的编解码器
- ffp:介于0与1.0之间,用来确定期望的假阳率(false positive probability)。可以依据每个索引段中的文档树设置多个ffp值。例如,默认情况下 10k 个文档时为 0.1,1m 个文档时为 0.03。表示当索引端中文档数大于 10 000 个时 ffp 值使用 0.01,而当文档数超过 1 000 000 时,ffp 值使用 0.03。
准实时、提交、更新及事务日志
索引更新及更新提交
索引期新文档会写入索引段,索引段是独立的 Lucene 索引,这意味查询可以和索引并行,只是不时会有新增的索引段被添加到可被搜索的索引段集合之中。Lucene 通过创建后续的(基于索引只写一次的特性)segments_N 文件来实现此功能,且该文件列举了索引中的索引段。该过程成为提交(committing),Lucene 以一种安全的方式来执行该操作,能确保索引更改以原子操作方式写入索引,即便发生错误,也能保证索引数据的一致性。
一次提交并不足以保证新索引的数据都能被搜索到,Lucene 使用了一个叫作 Searcher 的抽象类来执行索引的读取。如果索引更新提交了,但 Searcher 实例并没有重新打开,它察觉不到新索引段的加入。Searcher 重新打开的过程叫作刷新(refresh)。考虑性能 Lucene 推迟了耗时的刷新,不会在每次新增一个文档(或批量增加文档)时刷新,但 Searcher 会每秒刷新一次。这已经很频繁了,但有些应用仍然需要更快的刷新频率,需要使用其它技术或评估是否合理。ES 提供了强制刷新的 API,在搜索前执行该命令即可,如:
| |
更新默认的刷新时间
Searcher 自动刷新时间间隔可以通过以下手段改变:更改 ES 配置文件中的 index.refresh_interval 参数值或者使用配置更新相关的 API。例如:
| |
该命令将 Searcher 自动刷新时间间隔更改为 5 分钟。
刷新操作很消耗资源,因此刷新间隔时间越长,索引速度越快。如果需要长时间高速建索引,并在建索引结束之前暂不执行查询,可以考虑将 index.refresh_interval 设置为 -1,然后在建索引结束后再将该参数恢复为初始值。
事务日志
ES 通过使用事务日志(transaction log)来解决写数据失败的问题(磁盘空间不足、设备损坏、没有足够的文件句柄共索引文件使用等),它能保证所有的未提交的事务,而 ES 会不时创建一个新的日志文件用于记录每个事务的后续操作。当有错误发生时就会检查事务日志,必要时会再次执行某些操作,以确保没有提示任何更改信息。且事务日志的相关操作都是自动完成的,用户并不会意识到某个特定时刻触发的更新提交。事务日志中的信息与存储介质之间的同步(同时清空事务日志)称为事务日志刷新(flushing)。
注意事务日志刷新与 Searcher 刷新的区别。
除了自动的事务日志刷新以外,还可以使用对应的 API,如使用下面的命令强制将事务日志涉及的所有数据更改操作同步到索引中,并清空事务日志文件:
| |
也可以使用 flush 命令对特定的索引进行事务日志刷新(如 library 索引):
| |
事务日志相关配置
以下参数通过修改 elasticsearch.yml 文件来配置,也可以通过索引配置更新 API 来更改:
- index.translog.flush_threshold_period:默认值为30分钟,控制了强制自动事务日志刷新的时间间隔,即便没有新数据写入。强制进行事务日志刷新通常会导致大量的 I/O 操作,因此当事务日志涉及少量数据时,才更适合进行这项操作。
- index.translog.flush_threshold_ops:确定了一个最大操作数,即在上次事务日志刷新以后,当索引更改操作次数超过该参数时,强制进行事务日志刷新操作,默认值为 5000。
- index.translog.flush_threshold_size:确定了事务日志的最大容量,当容量大于等于该参数值,就强制进行事务日志刷新操作,默认值为 200MB。
- index.translog.disable_flush:禁用事务日志刷新。临时性禁用能带来其它便利,如向索引中导入大量文档。 除了修改 elasticsearch.yml 文件的方式,也可以通过设置更新 API 该更改相关配置。如:
| |
准实时读取
事务日志带来一个免费特性:实时读取(real-time GET),该功能让返回文档各种版本(包括未提交版本)成为可能。实时读取操作从索引中读取数据时,会先检查事务日志中是否有可用的新版本。如果近期索引没有与事务日志同步,那么索引中的数据将会被忽略,事务日志中的最新版本的文档将会被返回。如下命令:
| |
深入理解数据处理
输入并不总是文本分析
创建索引:
| |
添加文档:
| |
测试命令:
| |
第一个查询返回了目标文档,而第二个查询没有返回任何结果。该现象与文本分析有关。 通过下面命令来使用文本分析(Analyze)API:
| |
端点 _analyze 允许查看 ES 是如何处理 text 参数中的输入,也能指定要使用哪个分析器(通过 analyzer 参数)。 上面命令的返回结果会发现:每个词条都携带了在原始文本中的位置信息、类型信息(过滤器可能用到)、词项信息(一个词存储在索引中,在检索其用于与查询中的词项匹配)。原始文本被转换成了这些词项:quick、brown、fox、jump、over、lazi(有趣的变化)、dog。总计 snowball 分词器做了哪些事情:
- 过滤非重要次(如 the)
- 将单词转换为词干形式(如 jump)
- 有时会进行操作的转换(如 lazi) 第二个测试范例:
| |
查询结果:第一个没有返回(因为查询中的 lazy 文本与索引中的 lazi 并不相同),而第二个查询经过分词器处理,返回了预期的文档。
范例的使用
从概率的角度来看,与查询短语在文本上精确撇配的文档应该是用户最感兴趣的。从另一个重要指标来看,与用户输入短语中词语精确匹配的文档才是用户感兴趣的。这里的词语精确匹配既可以是语义上相同也可以是同一个词的不同形态。
创建一个只包含单字段文档的索引:
Expand/Collapse Code Block
| |
一个字段使用了两个分析器进行文本分析处理,因为 title 字段是 multi_field 类型。其中对 title.org 子字段使用了 standard 分析器,而对 title.i18n 子字段使用了 english 分析器(该分词器会将用户输入转换为词干形式)。 添加文档:
| |
此时,索引中的 title.dog 字段已有 jumps 词项,而 title.i18n 字段也有了jump词项。执行以下查询:
| |
文档由于跟查询完美匹配获得了较高的得分,归功于对 field.org 字段的命中做了加权处理。field.i18n 字段的命中也贡献了部分得分,只是对总得分影响小很多,因为其默认权值为1。
索引期更换分词器
Expand/Collapse Code Block
| |
允许 ES 在处理文本时根据文本内容决定采用何种分析器。path 参数为文档中的字段名,该字段保存了分析器的名称。其次移除了 field.i18n 字段所用分析器的定义。 创建索引:
| |
这个例子 ES 从索引中提取 lang 字段的值,并将该值代表的分析器置于当前文档的文本分析器处理。如在文档中移除或保留非重要词等场景非常有用。
搜索时更换分析器
也可以在搜索时更换分词器,并通过配置 analyzer 属性来实现。如:
| |
陷进与默认分析
索引期与检索期能针对文档更换分词器的机制是一个非常有用的特性,但也会引入非常隐蔽的错误。如没有定义分析器。这种情况 ES 会选用一个默认分析器,但这不是我们期望的。因为默认分析器有时候会被文本分析插件模块重定义。此时有必要指定默认分析器,如将自定义的分析器名称替换为default。
备选方案,定义 default_index 分析器和 default_search 分析器。
控制索引合并
ES 每个索引都会创建一到多个分片以及零到多个副本,本质上都是 Lucene 索引(基于多个索引端构建,至少一个)。索引文件绝大部分数据都是只写一次、读多次,自由用于保存文档删除信息的文件才会被多次更改。某种情况满足条件时,多个索引段会被拷贝合并到一个更大的索引段,而旧的索引段会被抛弃并从磁盘中删除,这个操作称为段合并(segment merging)。
段合并的原因:
1、索引段个数越多,搜索性能越低且耗费内存更多。
2、索引段是不可变的,物理上并不能从中删除信息。
3、碰巧索引中删除了大量文档,但文档只做了删除标记,物理机上没有被删除。段合并时,标记为删除的文档并没有复制到新的索引段中。因此减少了最终索引端中的文档数。
从用户角度段合并可概括为:
1、当多个索引端合并为一个时,会减少索引段的数量并提高搜索速度。
2、同时也会减少索引的容量(文档数),因为在段合并时会移除被标记为已删除的文档。
段合并的代价主要是 I/O 操作。在速度较慢的系统中会显著影响性能。因此 ES 允许用户选择段合并策略(merge policy)及存储级节流(store level throttling)
选择正确的合并策略
三种可用的合并策略:
- tiered(默认)
- log_byte_size
- log_doc 配置文件的 index.merge.policy.type 字段:
| |
一旦使用特定的段合并策略创建了索引,就不能被改变。但是可以使用索引更新API来改变该段合并策略的参数值。
tiered合并策略
ES 默认选项。能合并大小相似的索引段,并考虑每层允许的索引段的最大个数。需要注意单次可合并的索引段的个数与每层允许的索引段数的区别。
索引期,合并策略会计算索引中允许出现的索引段个数,称为阈值(budget)。如果正在构建的索引中的段数超过了阈值,该策略将先对索引段按容量降序排序(这里考虑了被标记为已删除的文档),然后再选择一个成本最低的合并。合并成本的计算方法倾向于回收更多删除文档和产生更小的索引段。
如果某次合并产生的索引段的大小大于 index.merge.policy.max_merged 参数值,则该合并策略会选择更少的索引段参与合并,是的生成的索引段大小小于阈值。即对于有多个分片的索引,默认的 index.merge.policy.max_merged 则显得过小,会导致大量索引段的创建,从而降低查询速度。用户应根据具体的数据量观察索引段的情况,不断调整合并策略以满足需求。
log byte zise合并策略
该策略会不断地以字节数的对数为计算单位,选择多个索引来合并创建新索引。合并过程中,会出现一些较大的索引段,然后又产生出一些小于合并因子(merge factor)的索引段,如此循环往复。可以想象一些相同数量级的索引段,其个数会变得比合并因子还少。对一些特别大的索引段,所有小于该级别的索引段都会被合并。索引中的索引段个数与下次用于计算的字节数的对数成正比。因此,该合并策略能够保持较少的索引段数量并且极少化段索引合并的代价。
log doc合并策略
与 log_byte_size 合并策略类似,不同的是 log_byte_size 基于索引的字节数计算,而 log_doc 基于索引段的文档数计算。以下两种情况该合并策略表现良好:
- 文档集中的文档大小类似
- 参数合并的索引段在文档数方面相当
合并策略配置
大多数情况下默认选项是够用的,除非有特殊的需求才需要修改。
配置tiered合并策略
- index.merge.policy.expunge_deletes_allowed:默认值为0,用于确定被删除文档的百分比,当执行 expungeDeletes 时,该参数用于确定索引段是否被合并。
- index.merge.policy.floor_segment:用于阻止频繁刷新微小索引段。小于该值的索引段由索引合并机制处理,并将这些索引段的大小作为该参数值。默认值为 2MB。
- index.merge.policy.max_merge_at_once:确定了索引期单次合并涉及的索引段数量的上限,默认为10.该值较大时,允许更多的索引段参与单次合并,只是会消耗更多的 I/O 资源。
- index.merge.policy.max_merge_at_once_explicit:确定索引优化(optimize)操作和expungeDeletes 操作能参与的索引段数量的上限,默认值为30。但该值对索引期参与合并的索引段数量的上限没有影响。
- index.merge.policy.max_merged_segment:默认值为 5GB,确定了索引期单次合并中产生的索引段大小的上限。这是一个近似值,因为合并后产生的索引段的大小是通过累加参与合并的索引段的大小并减去被删除文档的大小而得来的。
- index.merge.policy.segments_per_tier:确定每层允许出现的索引段数量的上限。越小的参数值会导致更少的索引段数量,意味更多的合并操作以及更低的索引性能。默认值为10,建议设置为大于等于 idnex.merge.policy.max_merge_at_once,否则会遇到很多与索引合并以及性能相关的问题。
- index.reclaim_deletes_weight:默认为 2.0,确定了索引合并操作中清除被删除文档这个因素的权重。如果设置为 0.0,则清除被删除文档对索引合并并没有影响。该值越高,则清除较多被删除文档的合并会更受合并策略青睐。
- index.compund_format:布尔型,确定了索引是否存储为复合文件格式(compound format),默认值为 false。true 则 Lucene 会将所有文件存储在一个文件中。这样设置有时能解决操作系统打开文件处理器过多的问题,但也会降低索引和搜索的性能。
- index.merge.async:布尔型,用来确定索引合并是否异步进行。默认为 true。
- index.merge.async_interval:当 index.merge.async 设置为 true 时(异步进行),该值确定了两次合并的时间间隔,默认为 1s。为了触发真正的索引合并以及索引段数量缩减操作,该值应该保持为一个较小值。
配置log byte size合并策略
- merge_factor:确定索引期间索引段以多大的频率进行合并。值越小搜索的速度越快,消耗的内存也越小,而代价则是更慢的索引速度。反之亦然。默认为 10,对于批量索引构建,可以设置较大的值,对于日常索引维护则可采用默认值。
- min_merge_size:定义了索引段可能的最小容量(段中所有文件的字节数)。如果索引段大小小于该参数值,且 merge_fator 参数值允许,则进行索引段合并。该参数默认值为 1.6MB,对于避免产生大量小索引段非常有用。设置较大值时,会导致较高的合并成本。
- max_merge_size:定义了允许参与合并的索引段的最大容量(以字节为单位)。默认不做设置,因而在索引合并时对索引段大小没有限制。
- maxMergeDocs:定义了参与合并的索引段的最大文档数。默认没有设置,因此当索引合并时对索引段没有最大文档数的限制。
- calibrate_size_by_deletes:该参数为布尔值,如果设置为 true,则段中被删除文档的大小会用于索引段大小的计算。
- index.compund_format:布尔值,确定了索引文件是否存储为复合文件格式,默认为 false。可参考 tiered 合并策略配置中该选项的解释。
配置log doc合并策略
- merge_factor:同上
- min_merge_docs:定义饿了最小索引段允许的最小文档数。如果某索引段的文档数低于该参数值,且 merge_factor 参数允许,就会执行索引合并。该参数默认值为 1000,对于避免产生大量小索引段非常有用。过大会增大索引合并的代价。
- max_merge_docs:定义了可参与索引合并的索引段的最大文档数。默认情况下,该参数没有设置,因而对参与索引合并的索引段的最大文档数没有限制。
- calibrate_size_by_deletes:布尔值,设置为 true,则段中被删除文档的大小会在计算索引段大小时考虑进去。
- index.compund_format:布尔值,确定了索引文件是否存储为复合文件格式,默认为 false。同上。
调度
除了可以影响索引合并策略的行为之外,ES 还允许定制合并策略的执行方式。索引合并调度器(scheduler)分为两种,默认的是并发合并调度器 ConcurrentMergeScheduler。
并发合并调度
该调度器使用多线程执行索引合并操作。具体过程是:每次开启一个新线程知道线程数达到上限,当达到上限时,必须开启新线层(因为需要进行新的段合并),那么所有索引操作将被挂起,直到至少一个索引合并操作完成。
为了控制最大线程数,可以通过修改 index.merge.scheduler.max_thread_count 属性来实现。一般可以按照如下公式来计算允许的最大线程数:
| |
如何系统是 8 核,那么调度器允许的最大线程数可以设置为 4。
顺序合并调度
使用同一个线程执行所有的索引合并操作。执行合并时,该线程的其它文档处理都会被挂起,从而索引操作会延迟进行。
设置合并调度
为了设置特定的索引合并调度器,用户可将 index.merge.scheduler.type 的属性值设置为 concurrent 或 serial。如使用并发合并调度器:
| |
使用顺序合并调度器:
| |
小结
本章学习了如何使用不同的评分公式及其好处,也了解了不同的倒排索引格式及其优点。还介绍了准实时搜索和实时读取以及 Searcher 刷新对 ES 的意义。另外还讨论了多语言数据处理和如何按需配置事务日志,最后介绍了索引的段合并、合并策略以及调度。
14.6.4 - 深入理解ES-04分布式索引架构
本章将学习以下内容:
- 如何为集群选择合适的分片数和副本数
- 路由是什么以及它对ES的意义
- 分片分配器如何工作、配置
- 怎样调节分片分配机制以满足应用需求
- 怎样确定应该在哪个分片上执行指定的操作
- 如何应对数据和查询数量的增长
选择合适的分片和副本数
随着应用的增长,需要规划索引及其配置使其适应应用的变化。每个应用程序都有各自的特性和需求,没有确切的秘诀,不仅索引结构如此,配置也是如此。例如,文档或索引的大小、查询类型以及期望的吞吐量都是其影响因素。
分片和过度分配
分片处理是将一个索引分割成若干更小索引的过程,从而能够在同一集群的不同节点上散布它们。查询时结果是索引中每个分片返回结果的汇总(如果某个分片包含所有数据就不是真的汇总)。ES 默认为每个索引创建 5 个分片,即使在单节点环境下也是如此。这种冗余称为过度分配(over allocation)。目前看起来没有必要,仅在索引(散布文档到分片)和处理查询(查询多个分片合并结果)时就增加了更多的复杂性。幸运的是复杂性被自动处理了。
如果一个索引由一个分片构成,那当应用程序的增长超过单一服务器的容量时就会遇到问题。因为当前的 ES 版本不能将索引分割成多份,必须在创建索引时就指定好需要的分片数量。用户所能做的就是只有创建一个拥有更多分片的新索引,并重新索引数据。ES 设计者选择的默认配置(5个分片和1个副本)使数据量增长和多分片搜索结果合并之间达到平衡。
如果有一个有限且明确的数据集,可以只使用一个分片。如果没有依照经验,最理想的分片数量应该依赖于节点的数量。因此,如果计划将来使用10个节点,需要给索引配置10个分片。为了保证高可用和查询的吞吐量,同样需要配置副本数,且它与普通分片一样需要节点上的空间。简单的计算公式为:节点最大数 = 分片数 * ( 副本数 + 1 )
过度分配的正面例子
一般应该使用更少的分片,但更多的分片意味着每个在较小的 Lucene 索引上执行的操作会更快(尤其是索引过程)。当然,将查询分散成对每个分片的请求,然后合并结果,也是有代价的,但这对于使用固定参数来过滤查询的应用程序是可以避免的。
案例:每个查询大偶在指定用户的上下文中执行的多租户系统。即把每个用户的数据都索引到一个独立分片中,查询时只查询该用户的分片(需要使用路由)。
多分片与多索引
如果一个分片是一个小的 lucene 索引,那什么是真正的 ES 索引?技术上来讲是一样的,差别只在一些针对索引或分片的额外特性上。对分片处理来说,目标查询的处理可能存在这些可能:查询被路由至特定分片执行,不同的查询又各自的执行偏好。对于索引,一个更通用的机制被应用于寻址,使用 /index1, /index2 等这样的记,查询可以被发送给不同的索引。可以使用别名让多个索引看起来像一个索引,类似分片处理。更多的不同可以从分片和索引的平衡逻辑上看出来,尽管自动化索引的不足一定程度上可以由手工部署索引(到指定节点)来掩盖。
副本
分片处理能存储超过单机容量的数据,而使用副本则解决了日渐增长的吞吐量和数据安全方面的问题。当一个存放主分片的节点失效后,ES 能将一个可用的副本升级为新的主分片。查询吞吐量会随着用户的增长而增长,而使用副本则可以应对增长的并发查询。使用过多副本的缺点也很明显:各个分片的副本占用了额外的存储空间,主分片及其副本之间的数据拷贝也存在时间开销。因此选择分片数量时,应当考虑所需要的副本数量。如果选择太多的副本,可能会耗光磁盘空间和ES的资源,而事实上这些副本很多时候根本用不到。但不创建副本则可能导致主分片发生问题时的数据丢失。
路由
路由是限定查询在单个分片上执行的一个解决方案,由此来应对增长的查询吞吐量。
分片和数据
通常情况下,ES将数据分发到哪个分片,以及哪个分片上存放特定的文档都不重要。查询时请求会发送至所有的分片,最关键的事情是使用一个能均匀分发数据的算法。当删除或增加一个文档的新版时,ES需要知道文档在哪个分片,这就是路由要做的事情。
测试路由功能
通过一个例子演示如何将文档存放到特定的分片上。
安装 Paramedic 插件:
| |
重启 ES 后,通过浏览器访问 http://lcoalhost:9200/_plugin/paramedic/index.html ,上面有索引相关的各种统计量和其它信息。这里重点关注象征集群状态的集群颜色以及各个索引的分片和副本列表。 创建索引:
| |
创建了只有 2 个分片但没有副本的索引。 索引文档:
| |
Paramedic 会展示 2 个主分片。集群中的每个节点都精准地容纳了 2 个文档。可以得出结论:分片算法完美地完成工作,文档在分片之间均匀分布。 人为关闭一个节点,集群状态会变成红色,丢失了一个主分片后数据的某些部分会缺失。尽管如此,ES还允许查询,至于是返回失败还是挂起查询,由应用构建者决定。这里查询所有文档,ES 会返回失败信息和命中的 2 个文档信息。
索引过程中使用路由
可以通过路由来控制 ES 将文档发送到哪个分片。路由参数值无关紧要可以取任何值。重要的是将不同文档放到同一个分片上时,需要使用相同的值。
向ES提供路由信息有多种途径,
第一种方法:
最简单的办法是在索引文档时加一个 URI 参数 routing,如:
| |
第二种方法:
还可以在文档里放一个 _routing 字段,如:
| |
这种情况仅在 mappings 中定义了 _routing 字段时才会生效,如:
| |
值得一提的是 path 参数可以指向文档中任意未分词字段。这个功能十分强大,也是路由特性最主要的优势所在。例如使用图书所在的图书馆的 library_id 字段扩展文档,那么当基于 library_id 设置路由时,有理由认为所有基于图书馆的查询更有效率。 第三种方法:
最后一种方式是在执行批量索引时使用路由。使用时路由值在每个文档的头部给出,如:
| |
索引时使用路由
仍然使用上面的例子,只是这次使用路由。先删除旧数据,否则使用相同的标识符添加文档时,路由会把相同的文档存放到另一个分片中。因此,删除旧数据:
| |
重新索引数据,添加这次的路由信息。索引文档的命令如下:
| |
可通过 Paramedic 页面验证文档路由到哪个分片。一个节点只有1个文档(路由值为B),另一个节点有3个文档(路由值为A)。停掉一个节点,Paramedic 会再次显示红色的集群状态。
查询
路由允许用户构建更有效率的查询,并且用户也可以使用路由,不必把查询发送到所有节点。如:
| |
会发现返回了3个文档(第一个节点挂掉),看起来很顺利,但第一个节点挂掉了并未启动,由此可见尽管没有一个完整的索引视图,ES 的响应中并未包含分片失败的信息。这证明了使用路由的查询只命中选定的分片,而忽略其它分片。如果改用路由值 B 会发现返回了异常。 需要注意,虽然路由确保了在索引时拥有相同路由值的文档会索引到相同的分片上,但一个给定的分片上可以有很多拥有不同路由值的文档。路由可以限制查询时使用的节点数,但是不能替代过滤功能。这意味着一个查询有没有使用路由都应该相同的过滤器。
别名
对程序员隐藏一些配置信息,让其可以快速工作而不必关心搜索细节如路由、分片、副本。创建别名:
| |
创建了一个叫 documentsA 的虚拟索引(一个别名),用来代表来自 documents 索引的信息。除了这个查询还被限定在路由值 A 相关的分片上。
多个路由值
ES 允许在一次查询中使用多个路由值。文档放置在哪个分片上依赖于文档的路由值,多路由值查询意味在一个或多个分片上查询。如:
| |
另外,多路由值也支持别名,如:
| |
上面的例子可以为查询和索引配置不同的路由。定义查询时(search_routing参数)使用两个路由值(A和B),而索引时(index_routing参数)仅使用了一个路由值(A)。注意:索引时不支持多个路由值,同时要做适当的过滤(可以把它加到别名中)
调整默认分片分配行为
分片分配器简介
分片分配器 ShardAllocator 是承担分片分配职责最主要的类。ES集群上的数据分布发生变动时,分片也有相应的变动。例如集群的拓扑结构发生改变(当加入或移除节点)或通过强制重新平衡时。在内部分配器是 org.elasticsearch.cluster.routing.allocation.allocator.shardsAllocator 接口的一个实现。ES提供了下面两种类型的分配器:
- even_shard
- balanced(默认) 通过在 elasticsearch.yml 中设置 cluster.rounting.allocation.type 属性或者使用设置 API,可以指定具体的分配器实现。
even_shard分片分配器
ES 0.90.0 之前的版本就有这个分配器了。它能确保每个节点上就有相同数量的分片(当然,并不是总能满足这样的情形),同时也能禁止主分片及其副本存储在同一节点上。在需要重新分配且使用 even_shard 分片分配器时,ES从存储负载最高的节点向存储负载较低的节点移动分片,直到集群完全平衡或者无法移动。需要注意,这个分配器并排工作在索引级别。意味着只要分片及其副本在不同的节点上,分配器就认为工作正常,而不关心来自同一索引的不同分片存放在哪里。
balanced分片分配器
ES 0.90.0 之后新引入的。基于一些可控制的权重进行分配。相比 even_shard 分片分配器,通过暴露一些参数而引入调整分配过程的能力,从而可以通过使用集群更新 API(update API)动态改变这些参数,可调整的参数如下:
- cluster.routing.allocation.balance.shard:默认值为 0.45。基于分片总数的权重。告诉ES基于所有的分片数,每个节点都分配数量相近的分片的重要性。
- cluster.routing.allocation.balance.index:默认值为 0.5。基于给定索引的分片数的权重。基于同一个索引的分片数,每个节点都分配数量相近的分片的重要性。
- cluster.routing.allocation.balance.primary:默认值为 0.05。基于主分片的权重。将主分片平均分配到节点的重要性。
- cluster.routing.allocation.balance.threshold:默认值为 1.0 如果集群的所有节点上平均分配很重要,应该提高 cluster.routing.allocation.balance.primary 的权重值,并适当降低出阈值外的其他权重值。
如果所有因子与其权重乘机的总和大于已定义的阈值,那么这类索引的分片就需要重新分配了。如果需要忽略一个或多个因子,将它们的权重设置为 0 就可以。
自定义分片分配器
内置的分片分配器肯跟不适合应用场景,如需要在分配时考虑索引大小的不同。或者继承各种不同硬件的大集群,不同的 CPU、内存容量或磁盘容量。所有这些因素都可能导致集群数据分布的低效。
可以定制解决方案,cluster.routing.allocation.type 必须设置成一个类的全限定名,且该类需要实现 org.elasticsearch.cluster.routing.allocation.allocator.ShardsAllocator 接口。
裁决者
分片分配器如何决定决定分片移动的时机和目标节点,主要是ES内部的裁决者(decider)做出分配决定的大脑。ES允许同时使用多个裁决者,而且它们会在裁决过程中投票。有个规则共识:如果某裁决者投票反对重新分配一个分片的操作,那么该分片就不能移动。裁决者只有固定的十来个,如果想添加新的决策者,只能通过修改 ES 源码。
SameShardAllocationDecider
该裁决者禁止将相同数据的拷贝(分片和其副本)放到相同的节点上。原因很明显:不希望再保存主数据的地方保留其备份数据。但 cluster.routing.allocation.same_shard.host 属性控制了 ES 是否需要考虑分片放到物理机器上的位置。默认为 false,因为许多节点可能运行在同一台运行着多个虚拟机的服务器上。当设置成 true 时,这个裁决者会禁止将分片和其副本放置在同一台物理机器上。不过最好还是依赖像 index.routing.allocation 属性族的配置。
ShardsLimitAllocationDecider
该裁决者确保一个给定索引在某节点上的分片不会超过给定数量。该数量是由 index.routing.allocation.total_shards_per_node 属性控制的,可以在 elasticsearch.yml 文件中设置,或者通过索引更新 API 在线更新。属性的默认值是 -1,表明没有限制。注意:调低该值会强制重新分配,在重新平衡期间会给集群带来额外额外的负载。
FilterAllocationDecider
该裁决者只在增加了控制分片分配的参数时才会用到。这类参数需要匹配 .rounting.allocation. 名称模式。
ReplicaAfterPrimaryActiveAllocationDecider
该裁决者使得ES仅在主分片都分配好之后才开始分配副本。
ClusterRebalanceAllocationDecider
该裁决者允许根据集群的当前状态改变集群进行重新平衡的时机。可以通过 cluster.routing.allocation.allow_rebalance 属性来控制,它支持以下这些值:
- indices_all_active:默认值表明重新平衡仅在集群中所有已存在的分片都分配好后才能进行
- indice_primaries_active:表明重新平衡只在主分片分配好以后才进行
- always:设置表明重新平衡总是可以进行,甚至在主分片和副本还没有分配好时可以 注意:这些设置在运行时不能更改。
ConcurrentRebalanceAllocationDecider
该裁决者用于调节重新部署操作,并基于 cluster.routing.allocation.cluster_concurrent_rebalance 属性。该属性可以设置给定集群上可以并发执行的重新部署操作的数量,默认是 2 个,意味着在集群上只有不超过 2 个的分片可以同时移动。可这个值设为 -1 将取消限制,从而使重新部署操作的并发数量没有限制。
DisableAllocationDecider
该裁决者可以调整自身行为来满足应用需求的裁决者。为了利用该裁决者的特性,可以更改下面的这些设置(修改 elasticsearch.yml 或使用集群设置 API):
- cluster.routing.allocation.disable_allocation:设置允许禁止所有的分配。
- cluster.routing.allocation.disable_new_allocation:设置允许禁止新主分片的分配。
- cluster.routing.allocation.disable_replica_allocation:设置允许禁止副本的分配。 设置的默认值都是 false。在完全控制分配时非常有用。当要快速地重新分配和重新启动一些节点时,可以禁止重新分配。尽管在 elasticsearch.yml 文件里设置前面提到的那些属性,使用更新 API 会更有意义。
AwarenessAllocationDecider
该裁决者用来处理意识部署功能的。任何时候使用了 cluster.routing.allocation.awareness.attributes 设置都会起作用。
ThrottlingAllocationDecider
与 ConcurrentRebalanceAllocationDecider 类似,允许限制分配过程产生的负载。可以使用下面的属性控制恢复过程:
- cluster.routing.allocation.node_initial_primaries_recoveries:默认值为 4。描述了单节点所允许的最初始的主分片恢复操作的数量。
- cluster.routing.allocation.node_concurrent_recoveries:默认值为 2。定义了单节点并发恢复操作的数量。
RebalanceOnlyWhenActiveAllocationDecider
该裁决者限制重新平衡过程仅在分片组(主分片和其副本们)内的所有分片都是活动的(active)情况下进行。
DiskThresholdDecider
该裁决者在 ES 0.90.4 版本引入,允许基于服务器上的空余磁盘容量来部署分片。默认禁用,必须设置 cluster.routing.allocation.disk.threshold_enabled 属性为 true 来启用。且允许配置一些阈值来决定何时将分片放到某个节点上以及 ES 应该何时将分片迁移到另一个节点。
- cluster.routing.allocation.disk.watermark.low 允许在分片分配可用时指定一个阈值或绝对值。默认值是 0.7,即告诉 ES 新分片可以分配到一个磁盘使用率低于 70% 的节点上。
- cluster.routing.allocation.disk.watermark.high 允许在某分片分配器试图将分片迁移到另一个节点时指定一个阈值或绝对值。默认值是 0.85,意味着 ES 会在磁盘空间使用率上升到 85% 时重新分配分片。 这两个属性都可以设置成百分数(如0.7或0.85),或者绝对值(如1000mb)。另外,本节提到的所有属性都可以在 elasticsearch.yml 里静态设置,或使用 ES API 动态更新。
调整分片分配
除了可以使用单个 API 命令在集群内移动分片,对于分片分配而言,还可以定义一系列分片分配的规则。
例如,假设有 4 个节点的集群,每个节点都绑定了一个指定的 IP 地址,且都被赋予了一个 tag 属性和一个 group 属性(在 elasticsearch.yml 文件里对应的是 node.tag 和 node.group 属性)。这个集群用来展示分片分配的过滤处理如何工作。可以给 tag 和 group 属性任意命名,只需要给期望的属性名前面加上 node 前缀即可。如将 party 作为属性名,只需要把 node.party: party1 加入到 elasticsearch.yml 文件里。

部署意识
部署意识允许使用通用参数来配置分片及其副本的部署。使用这里的例子集群来证明部署意识如何工作的。在 elasticsearch.yml 文件里加入下列属性:
| |
这会告诉 ES 使用 node.group 属性作为意识参数。
可以指定多个值给该属性,如:cluster.routing.allocation.awareness.attributes: group, node
然后先启动前两个节点,即 node.group 属性值是 groupA 的那两个。接下来创建一个索引:
| |
执行前面的命令后,两个节点的集群看起来类似于下面的截图:

索引平均部署到了两个节点上。换成另外两个节点时会发生什么(node.group 设置成 groupB 的那两个):

注意区别:主分片没有从原来部署的节点上移动,但是副本分片却移动到了有不同 node.group 值的节点上。这恰恰是正确的。当使用分片部署意识时,ES不会将分片和副本放到拥有相同属性值(用来决定部署意识,如例子中的 node.group)的节点上。例如从虚拟机或物理位置的角度分割集群的拓扑结构,以确保不会有单点故障。
在使用部署意识时,分片不会被部署到没有设定指定属性的节点上,所以对这里的例子来说,一个没有设置 node.group 属性的节点时不会被部署机制考虑的。
强制部署意识
预先知道意识参数需要接受几个值,且不超过部署到集群中的副本数时(如不想因过多的副本而使集群过载),有了强制部署意识就很方便。可以强制部署意识由特定属性激活来实现这个。通过使用 cluster.routing.allocation.awareness.force.zone.values 属性并给它提供一个用逗号分隔的列表来指定这些属性值。例如,希望对于部署意识来说只使用 node.group 属性的 groupA 和 groupB 两个值,应该把下面的代码加到 elasticsearch.yml 文件中:
| |
过滤
ES允许在整个集群或是索引的级别配置分片的分配。其中在集群级别上可以使用带有下面前缀的属性:
cluster.routing.allocation.include
cluster.routing.allocaiton.require
cluster.routing.allocation.exclude 而处理索引级分配时,使用带有下面前缀的属性:
index.routing.allocation.include
index.routing.allocation.require
index.routing.allocation.exclude 上述前缀可以在 elasticsearch.yml 文件里定义的属性一起使用( tag 属性和 group 属性)。只需使用一个名为 _ip 的特殊属性,就可以使用 IP 地址来进行包含或排除特定的节点。
例如:
| |
如果希望包含一组 group 属性是 groupA 的节点,应该设置下面的属性:
| |
注意,这里已经使用了 cluster.routing.allocation.include 前缀,并把它和属性名 group 连接在一起。 属性含义:
- include:包含所有定义了某参数的节点,如果定义了多个条件,那么至少匹配一个条件的节点都会在分配分片时被考虑进去。如增加两个 cluster.routing.allocation.include.tag 参数到配置中,一个赋值 node1,另一个赋值 node2,那么索引(确切地说是它们的分片)会被分配到第一个和第三个节点上(从左向右)。总结:拥有 include 参数类型的节点,ES 在选择防止分片的节点时会加以考虑,但这并不意味 ES 一定会把分片放到这些节点上。
- require:该属性在 ES 0.90 版本的分配过滤器中引入的。要求所有节点都必须拥有和这个属性相匹配的值。如向配置中添加 cluster.routing.allocation.require.tag 参数并赋值 node1,添加 cluster.routing.allocation.require.group 参数并赋值 groupA,那么所有的分片都分配在第一个节点上(IP地址为 192.168.2.1 的节点)。
- exclude:该属性允许在分片分配过程中排除具有特定属性的节点。例如,给 cluster.routing.allocation.include.tag 赋值 groupA,那么索引只分配在 IP 地址是 192.168.3.1 和 192.168.3.2 的节点上(例子中的第三个和第四个节点)
属性值可以使用简单的通配符。例如,要包含所有 group 属性值以 group 开头的节点,应当设置 cluster.routing.allocation.include.group 属性的值为 group*。就样例集群来说。这会导致匹配 group 参数值是 groupA 和 groupB 的节点。
运行时更新分配策略
除了在 elasticsearch.yml 文件里设置这些属性外,在集群已经启动运行后,也可以通过更新 API 来实时更新这些设置。
索引级更新
更新一个给定索引(如 mastering 索引)的设置,执行下面的命令:
| |
命令发送到了指定索引的 _settings 端点,可以在一次调用中包含多个属性。
集群级更新
更新整个集群的设置,执行下面的命令:
| |
依据命令的内容和索引的分配情况,执行前面的命令可能造成分片在节点间的移动。
确定每个节点允许的总分片数
除了前面提到的属性,还能定义每个节点可分配给索引的分片总数(主分片和副本)。为了实现该目的,需要给 index.routing.allocation.total_shards_per_node 属性设置一个期望值。如在 elasticsearch.yml 文件里做如下设置:
| |
这样单个节点上最多会为同一个索引分配 4 个分片。 该属性同样可以在一个运行中的集群上使用更新 API 来改变,如:
| |
下面举例说明 elasticsearch.yml 文件中使用分片分配属性后,以及创建单一索引时集群是什么样子。
include(包含)
创建 mastering 索引:
| |
然后执行下面的命令:
| |
如果把索引状态命令的响应可视化,那么集群与下图类似:

masterting 索引的分片部署到了 tag 属性为 node1 或 group 属性为 groupA 的节点上。
require(必须)
仍然使用前面的范例集群(假定集群里没有任何索引),观察 require(必须)分配如何工作。创建 mastering 索引:
| |
然后执行下面的命令:
| |
如果把索引状态命令的响应可视化,那么集群与下图类似:

该视图跟前面使用 include 选项的不一样。这是由于 ES 将 mastering 索引的分片仅分配到与两个 require 参数都匹配的节点上,而例子中满足两个都匹配的只有第一个节点。
排除
还使用前面的范例集群,创建 mastering 索引:
| |
然后执行下面的命令测试 exclude(排除)分配:
| |
再看看集群:

如图所实施,要求 group 属性必须等于 groupA,同时希望排除 tag 等于 node1 的节点。这导致一个 mastering 索引的分片被分配到了 IP 地址为 192.168.2.2 的节点,如期望一致。
更多的分片分配属性
除了上面讨论过的,ES还允许使用一些分片来分配相关的属性。
- cluster.routing.allocation.allow_rebalance:该属性允许基于集群中所有分片的状态来控制执行再平衡(rebanlance)的时机。可选的值包括:always,使用该值时再平衡会按需执行,而不管索引分片的状态(谨慎使用该值,会造成很高的负载);indices_primaries_active,使用该值当所有主分片时活动状态时在平衡会立刻执行;indices_all_active,使用该值再平衡在所有分片(主分片和副本)都分配好以后执行。默认值是 indices_all_active。
- cluster.routing.allocation.cluster_concurrent_rebanlance:该属性控制集群内有多少分片可以并发参与再平衡处理,默认值为2。该值设置较高会造成较高的I/O,并增加网络开销和节点的负载。
- cluster.routing.allocation.node_initial_primairies_recoveries:该属性指定了每个节点可以并发恢复的主分片数量。基于主分片恢复通畅比较快,即使把属性值设置得比较大, 也不会给节点本身带来过多压力。默认值为4。
- cluster.routing.allocation.node_concurrent_recoveries:该属性指定了每个节点允许的最大并发恢复分片数,默认值是2。指定过多的并发恢复的分片数会造成非常频繁的I/O活动。
- cluster.routing.allocation.disable_new_allocation:默认值为false。该属性用来控制是否禁止为新创建的索引分配新分片(包括主分片和副本分片)。该属性在希望延期分配新建的索引或分片时非常有用。也可以通过设置 index.routing.allocation.disable_new_allocation 属性为 true 来禁止向某个给定索引分配新的分片。
- cluster.routing.allocation.disable_allocation:默认值为false。用来控制是否禁止针对已创建的主分片和副本分片的分配。注意将一个副本分片提升为主分片(如果主分片不存在)的行为不算分配,因此这样的操作在属性为 true 时也是允许的。该属性在希望某段时间内禁止新建索引分配分片时非常有用。通过在索引的配置里设置 index.routing.allocation.disable_allocation 属性为 true,可以完全禁止向指定索引的分片分配。
- cluster.routing.allocation.disable_replica_allocation:默认值为false。设置为 true 时将会禁止将副本分片分配到节点。某时段停止分配副本分片时非常有用。也可以通过设置 index.routing.allocation.disable_replica_allocation 属性为 true 来禁止向一个给定索引分配副本。 前面所有的属性既可以在 elasticsearch.yml 文件里设置,也可以通过更新 API 来设置。在实践中通常只使用更新 API 来配置一些属性,如 cluster.routing.allocation.disable_new_allocation 、cluster.routing.allocation.disable_allocation 、 cluster.routing.allocation.disable_replica_allocation。
查询执行偏好
还可以指定查询(以及其他操作,如实时获取)在哪里执行。
了解细节之前,先查看范例集群:

如图集群中有三个节点和一个叫作 mastering 的索引。索引被分割为两个主分片,每个主分片有一个副本。
介绍preference参数
为了控制所发送查询(和其他操作)执行的地点,可以使用preference参数,可以取下面这些值:
- _primary:使用这个属性,发送的操作仅会在主分片上执行。如果想 mastering 索引发送一个查询请求,并将 preference 参数设置为 _primary,那么请求会在 node1 和 node2 上执行。如用户知道主分片在某个机柜,而副本在其它机柜,可能希望通过在主分片上执行操作来避免网络开销。
- _primary_first:该属性的行为很像 _primary,只是当主分片由于某些原因不可用时可以转而使用其副本,但它有一个自动故障恢复机制。如果向 mastering 索引发送一个查询请求,并设置 preference 参数为 _primary_first,那么查询会在 node1 和 node2 上执行。而一旦一个(或多个)主分片失败,查询会在另一个分片上执行,比如例子中这个分片分配在 node3 上。
- _local:ES在可能的情况下会优先在本地节点上执行操作。如发送一个查询请求给 node3 同时将 preference 参数设置为 _local,那么查询就会在该节点上执行。在最小化网络延时时尤为有用。
- _only_node:xxx:该操作只会在拥有置顶标识符的节点上执行。注意:如果没有足够的分片来覆盖所有的索引数据,查询只会在指定节点的可用分片上执行。
- _prefer_node:xxx:该选项设置 preference 参数为 _prefer_node,后面跟着某节点的标识符。使ES优先在指定的节点上执行查询,但如果该节点上一些分片不可用时,ES会将恰当的查询部分发送给分片可用的节点。与 _only_node 选项类似,_prefer_node 可以用来选择一个特定的节点,只是当特定节点不可用时可以转而使用其他节点。
- _shards:0,1:既指定操作将在哪个分片上执行,也是唯一一个可以和其他选项值组合的 preference 参数值。如:0,1;_local,表示为了在本地的 0 和 1 分片上执行查询,用分号连接 0,1 和 _local。对调试而言允许在一个分片上执行查询非常有用。
- custom:自定义值,确保具有相同值的查询会在相同的分片上执行。例如,发送一个 preference 参数值为 masterIng_elasticsearch 的查询,那么查询会在名称是 node1 和 node2 的节点的主分片上执行。如果发送另一个具有相同 preference 参数值的查询,那么该查询将在相同的分片上执行。该功能在需要有不同的刷新频率,并且不希望用户在重复查询时看到不同结果时尤为有用。 ES 默认会在分片和副本之间随机执行操作。如果发送大量请求,那么最终每个分片和副本上将会执行相同(或者几乎相同)数量的查询。
应用知识
基本假定
数据量和查询说明
假设有一个在线图书馆,当前销售着大约 10 万本不同语言的图书。
期望平均的查询响应时间小于等于 200 毫秒,避免用户在输入查询之后或搜索结果呈现之前等待太长时间。
通过一些性能测试(暂略),发现当数据分成两个分片,每个分片一个副本时,4个节点的集群工作状态时最好的。
可以使用开源工具来对集群发起请求做性能测试,例如 ApacheJMter(参见 http://jmeter.apache.org/ )或 ActionGenerator(参见 https://github.com/sematext/ActionGenerator )。此外还可以使用 ES API (通过类似 paramedic 的插件(参见 https://github.com/karmi/elasticsearch-paramedic ))来查看统计记录,或者使用 BigDesk(参见 https://github.com/lukas-vlcek/bigdesk ),又或者使用一个全面监控和可调节的解决方案,类似于来自 Sematext 的 SPM for ElasticSearch(参见 http://sematext.com/spm/elasticsearch-performance-monitoring/index.html )
集群类似:

当然确切地分片和副本的位置可能不同,但背后的逻辑相同,即:每个节点上有一个分片。
配置
创建集群的配置信息:
Expand/Collapse Code Block
| |
节点级配置
指定了集群名(使用 cluster.name 属性)来识别集群。如果相同网络内存在多个集群,那么拥有相同集群名称的节点会尝试相互连接并组成集群。将节点设置为可被选举为主节点(node.master 属性为 true),并使之能容纳索引数据(node.data: true)。除此之外,将 node.max_local_storage_nodes 属性设为 1,为了避免单个节点上运行多个 ES 实例。
索引级配置
这里只有一个索引且不会增加。设置默认的分片数为 2 (index.number_of_shards 属性),默认的副本数为 1(index.number_of_replicas 属性)。设置 index.routing.allocation.total_shards_per_node 属性为 1,意味着 ES 会针对每个索引在相应节点上放置一个分片,这里集群有 4 个节点,分片能够在节点上平均分配。
目录布局
将 ES 安装到 /usr/share/elasticsearch,因而 conf、plugins 和 work 目录也都相应配置到了该目录中。数据放置在一个特别指定的硬盘驱动器上,可通过 /mnt/data/elasticsearch 挂载点访问。日志文件存储在 /var/log/elasticsearch 目录中。这样的目录布局,在更改时只需要考虑 /usr/share/elasticsearch 目录,而不用关心其它目录。
网关配置
网关是负责存储索引及其元数据的模块。这里选择了推荐的并且唯一未废弃的网关类型,即 local(gateway.type 属性)。另外也希望恢复进程在有 3 个节点时(gateway.recover_after_nodes 属性),且在至少 3 个节点相互连接上 30 秒(gateway.recover_after_time 属性)之后立即执行。此外通过设置 gateway.expected_nodes 能告知 ES 集群将由 4 个节点组成。
恢复
集群恢复是一个最关键的配置。设置 cluster.routing.allocation.node_initial_primaries_recoveries 属性为 1,意味着仅允许每个节点上同时回复 1 个主分片。因为已设置每个节点上只运行一个 ES 实例。这个操作对于 local 网关类型非常快,因此如果每个节点存在一个以上的主分片则该值可以设置得更大些。也将 cluster.routing.allocation.node_concurrent_recoveries 属性设为 1 来限制每个节点上同时进行的恢复操作数(每个节点上只有一个分片,因此没有命中该规则,但如果每个节点有更多分片且 I/O 负荷允许,那么可以设置一个较大值)。此外将 indices.recovery.concurrent_streams 属性设为 8,因为在恢复操作的初始测试期间,网络和服务器在从对等分片回复一个分片时可以轻松使用 8 个并发文件流,基本上意味 8 个索引文件被同时读取。
发现
发现模块的配置仅设置了一个属性 discovery.zen.minimum_master_nodes 的属性值为 3。它能指定组成集群所需的且可以成为主节点的最小节点数,取值至少是集群节点的一半加 1,这里的例子是 3。会避免出现集群中的节点由于一些故障断开组成了一个同名的新集群(所谓的脑裂状况)。这种情形非常危险,会造成数据损坏(因为两个新组成的集群会同时索引和改变数据)。
记录慢查询
协同ES工作时,记录执行时间大于等于某个阈值的查询非常重要。注意:这个日志记录的不是查询的全部执行时间,而是查询在每个分片上执行的时间,意味着日志记录的只是执行时间的一部分。这个例子里,想使用 INFO 级日志记录超过 500 毫秒的查询和执行超过 1 秒的实时读取操作。对于调试级日志,分别设置为 100 毫秒和 200 毫秒。配置片段如下:
| |
记录垃圾回收器的工作情况
想要搞清楚是否以及何时垃圾回收器消耗了太多时间,在 elasticsearch.yml 文件中加入下面几行:
| |
通过使用 INFO 级日志,当并发标记清除(concurrent mark sweep)执行等于或超过 5 秒时,以及新生代收集(younger generation collection)执行超过 700 毫秒时,ES 会记录垃圾回收器工作超时的信息。同时也加上 DEBUG 级日志,用来应对我们想要调试或者修复问题的情况。
内存设置
假设节点各有 16G 内存,通常的建议是不要使 Java 虚拟机堆的大小超过可用内存总量的 50%,对当前这个例子来说也是一样。将 Xmx 和 Xms Java 的属性设为 8G,对这个例子来说足够了,因为索引不大,另外数据里也没有父子关系(parent-child relationships),更没有在高基数字段上做切面计算。前面设置了 ES 记录垃圾回收器的信息,为了长期监控,可能还需要使用类似 SPM(http://sematext.com/spm/index.html )或 Munin(http://munin-monitoring.org/ )的监控工具。
注意:举例如果索引占用了约 30G 的磁盘空间,即使共有 128G 内存,但是由于大量的父子关系和高基数字段 faceting,还是发生了内存溢出异常,甚至已经给 Java 虚拟机堆分配了 64G 内存。这种情况不必完全避免分配超过 50% 的总可用内存给 Java 虚拟机。应该要具体情况具体分析,前面的例子索引大小远远小于从 128G 分出 64G 给 Java 虚拟机后剩余的可用内存,可以增加 Java 虚拟机的内存使用量,但是要记得留下足够的内存,这样交换(swapping)就不会在系统里发生了。
还有一个属性 bootstrap.mlockall 属性,设置为 true 表示允许 ES 锁定堆内存,确保内存不会被交换。同时,也建议把 ES_MIN_MEM 和 ES_MAX_MEM 变量设置成同样的值,以确保服务器有足够的空余物理内存来启动 ES,且留有足够的内存给操作系统,使其能完美工作。
变化
如果图书文档数量扩大 20 倍,意味着需要调整 ES 集群,保证用户又相同或者更好的搜索体验。最容易做的无需额外工作的事情就是增加副本的数量,但缺点是副本需要更多的磁盘空间,其次要确保额外的副本可以分配到集群的节点中(参考本章最开始的公式)。此外还需要做性能测试,因为最终的吞吐量总是依赖很多因素,不能通过数学公式来描述。
如果过度分配了分片,就为预期的增长留下了空间,但这个例子中只有 2 个分片,对于 10 万的数据量来说非常合适,但是对于处理 210 万的数据量来说太小了。
重新索引
方案一是删除老的索引,然后新建一个新的有着更大分片数的索引。这是最简单的解决方案,但会导致服务在重建索引期间不可用。因为索引文档的成本很高,而且索引整个数据库的时间很长。公司的业务方会认为因重建索引而暂停这么长时间的服务是不可接受的。
第二个方案是再建一个新索引,向它灌数据然后在合适时将应用程序切到新索引上。作为一个选择,可以通过别名来使用新索引,而不用影响应用程序的配置。但是这样仍存在一个小问题,即创建新索引需要额外的磁盘空间,当然可以购买有着更大磁盘的新机器(必须索引新的大数据),但是在它们到位前,也应该能应付所有这些耗时的任务。
路由
使用路由最明显的好处是能创建有效查询(仅返回我们的基础数据集或属于我们业务伙伴的图书),这是因为路由能允许我们只在部分索引中查询。必须谨记应使用适当的过滤器,路由不能保证来自两个数据源的数据分别处在不同的分片上。所以引入路由也需要重建索引,该方案被否决。
多个索引
ES 允许无需额外的开销就可以在多个索引上进行搜索。可以在 API 端点上使用多个索引,如 /books,partner1/。还有一个更有弹性的方式允许方便快速添加另一个伙伴,而不用对应用程序做任何改动,也不需要停止服务。可以使用别名定义虚拟索引,不需要改变应用程序代码。
头脑风暴之后决定选择最后的解决方案,同时加上一些让 ES 在索引期间承受较小压力的额外改进。如下所示:禁用刷新率(refresh rate)同时删除分片副本:
| |
小结
本章学习了如何为 ES 的部署选择正确的副本分片数。也了解了在查询和索引时路由如何起作用。同时学习了分片分配器如何工作以及需要怎样的配置才能满足需求。然后按照需求配置了分片的分配机制,学习了如何选择查询执行偏好来指定操作在哪个节点上执行。最后配置了一个贴近现实的范例集群,并在需要时扩展了它。
14.6.5 - 深入理解ES-05管理ES
本章将会学习:
- 如何选择正确的目录实现,使得 ES 能够以高效的方式访问底层 I/O 系统
- 如何配置发现模块来避免潜在的问题
- 如何配置网关模块以适应需求
- 恢复模块能带来什么,以及如何更改它的配置
- 如何查看段信息
- ES 的缓存是什么样的,职责是什么,如何使用以及更改它的配置
选择正确的目录实现-存储模块
存储模块非常重要,该模块允许用户控制索引的存储方式。如可以持久化存储(存储在磁盘上)或非持久化存储(存储在内存中)。ES 的大多数存储类型与 Apache Lucene 中 Directory 类的子类是一一对应的。而目录能存取构成索引的各种文件,因此对其做适当的配置也是至关重要的。
存储类型
ES 提供了 4 种可用的存储类型。
简单文件系统存储
最简单的目录类的实现,使用一个随机读写文件(Java RandomAccessFile: http://docs.oracle.com/javase/7/docs/api/java/io/RandomAccessFile.html )进行文件操作,并与 Apache Lucene 的 SimpleFSDirectory 类对应(http://lucene.apache.org/core/4_5_0/core/org/apache/lucene/store/SimpleFSDDirectory.html )。对于简单的应用来说是够用的,但瓶颈主要是在多线程访问上,即性能非常差。对于 ES 来说,最好使用基于新 I/O 的系统存储来代替简单文件系统存储。然后,如果确实需要使用这种存储类型,则需要将 index.store.type 属性设为 simplefs。
新I/O文件系统存储
该存储类型使用基于 java.nio.FileChannel(http://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.html )的目录实现,与 Apache Lucene 的 NIOFSDirectory 类对应。该实现允许多个线程在不降低性能的前提下访问同一个文件。如果想使用这个存储类型,需要将 index.store.type 属性设为 niofs。
MMap文件系统存储
该存储类型使用了 Apache Lucene 的 MMapDirectory 实现。它使用 mmap 系统命令来读取和随机写入文件,并从进程的虚拟地址空间的可用部分中分出与被映射文件大小相同的空间,用于装载被映射文件。它没有任何锁机制,非常适合多线程访问。当使用 mmap 来为操作系统读取索引时就像它已经缓存过了一样(它被映射到了虚拟地址空间)。当从 Lucene 索引中读取某文件时,不需要把文件载入操作系统的缓存中,因此访问速度更快。基本上允许 Lucene 和 ES 去直接访问 I/O 缓存,从而获得了更快的索引文件访问速度。
需要注意,MMap 文件系统存储在 64 位环境下工作最佳,对于 32 位系统,只有确信索引足够小,且虚拟地址空间足够大时才可以使用。要使用此存储类型,需要将 index.store.type 属性设为 mmapfs。
内存存储
内存存储时唯一一个不是基于 Apache Lucene 目录的存储类型,它允许把全部索引都保存在内存中,因此文件并没有存储在硬盘上。这点很关键,意味着索引数据是非持久化的,无论何时,只要集群彻底重启索引数据就会被删除。然而,如果需要一份非常快的小索引,拥有多个分片和副本,且可以快速重建,那么内存存储可以作为你的选择。使用该存储类型,需要将 index.store.type 属性设为 memory。
内存存储中的数据与其它存储类型中的数据一样,会被复制到所有能容纳数据的节点上去。
附加属性
当使用内存存储类型时,也能一定程度上控制缓存,这点非常重要。注意以下设置都是节点级别的:
- cache.memory.direct:定义内存存储是否应该被分配到 Java 虚拟机堆内存之外,默认为 true。一般情况下应该保持为默认值,从而能避免堆内存过载。
- cache.memory.small_buffer_size:定义小缓冲区的大小,默认值是 1KB。小缓冲区是用来容纳段(segment)信息和已删除文档信息的内部内存结构。
- cache.memory.large_buffer_size:定义大缓冲区的大小,默认值是 1MB。大缓冲区是用来容纳出段信息和已删除文档信息外的索引文件的内部内存结构。
- cache.memory.small_cache_size:定义小缓存的大小,默认值是 10MB。小缓存是用来缓存段信息和已删除文档信息的内部内存结构。
- cache.memory.large_cache_size:定义大缓存的大小,默认值是 500MB。大缓存是用来缓存除段信息和已删除文档信息外的索引文件的内部内存结构。
默认存储类型
ES 默认使用基于文件系统的存储类型。针对不同的操作系统往往会选择不同的存储类型,但终究都使用了基于文件系统的存储类型。例如,ES 在 32 位的 Windows 系统上使用 simplefs 类型,在 Solaris 和 64 位的 Windows 系统上使用 mmapfs,其它系统则使用 niofs。
如果你想了解一些关于如何选择目录实现的专家见解, 请查看 Uwe Schindler 发表的 博文 http://blog.thetaphi.de/2012/07/use-lucenes-mmapdirectory-on-64bit.html ,以及 Jorg Prante 发表的博文 http://jprante.github.io/applications/2012/07/26/Mmap-with-Lucene.html 。
通常,默认的存储类型就是想使用的那个,但有时候需要使用 MMap 文件系统存储类型,尤其当有用很多内存,且索引又很大的时候。因为当使用 mmap 来访问索引文件时,索引文件只会被缓存一次,且可以被 Lucene 和操作系统重复使用。
发现模块的配置
ES 就是为集群而设计的,这是 ES 跟其它类似的开源解决方案的最大不同。通过发现机制(discovery mechanism)ES 极大地简化了设置集群所需的工作。
定义了想通 cluster.name 的节点会自动组成集群,这让我们可以在相同的网络中拥有多个独立的集群,但缺点是有时会忘记修改配置而意外地加入到了其它集群。这种情况下 ES 会重新平衡集群,转移一些数据到新加入的节点上。当该节点关机时,集群中的一些数据可能会魔术般地消失掉。
Zen发现
Zen发现(Zen discovery)是ES自带的默认发现机制。Zen发现默认使用多播来发现其它节点。这种解决方案非常快捷,一切顺利的话只要启动一个新的 ES 节点,并给它设置与集群相同的名字,它就会加入到集群中,并被其它节点探测出来。如果出现问题,应该检查 publish_host 或 host 设置,确保 ES 监听了正确的网络接口。
有时候多播会由于各种原因而失效,或者在一个大型集群中使用多播发现会产生大量不必要的流量,这可能都是不想使用多播的合理理由。这些情况下,Zen发现使用了第二种发现方法:单播模式。下面描述下这些模式的具体配置:
多播
多播(multicast)是ES的默认模式。当节点还没有加入任何集群时(如节点刚刚启动或重启),它会发出一个多播的 ping 请求,相当于通知所有可见的节点和集群,它已经可用并准备好加入集群了。
Zen发现模块的多播部分有如下配置:
- discovery.zen.ping.multicast.address:通信接口,可复制为地址或接口名。默认值是所有可用接口。
- discovery.zen.ping.multicast.port:通信端口,默认为 54328。
- discovery.zen.ping.multicast.group:代表发送多播信息的地址,默认为 224.2.2.4。
- discovery.zen.ping.multicast.buffer_size:缓冲区大小,默认为 2048。
- discovery.zen.ping.multicast.ttl:定义多播消息的生存期,默认为 3。每次包通过路由时,TTL 就会递减。这样可以限制广播接收的访问。注意,路由数的阈值设置可参考 TTL 的值,但要确保 TTL 的值不能恰好等于数据包经过的路由数。
- discovery.zen.ping.multicast.enabled:默认 true。如果打算使用单播方式二需要关闭多播时,可设置为 false。
单播
当节点不是集群中的一部分时(如刚刚重启,启动或由于某些故障脱离集群),它会发送一个 ping 请求给配置文件所指定的那些地址,通知所有的节点它准备好要加入集群了。
单播的配置非常简单,如下所示:
- discovery.zen.ping.unictas.hosts:代表集群中的初始化节点列表,可称之为一个列表或主机数组。每个主机可指定一个名称(或IP地址),还可以追加一个端口或端口范围。例如,属性值可以是:["master1", "master2:8181", "master3[80000-81000]"]。一般来说,单播发现的主机列表不需要是集群中所有ES节点的完整列表,因为新节点一旦与列表中的任何一个节点相连,就会知晓组成集群的其它全部节点的信息。
- discovery.zen.ping.unictas.concurrent_connects:定义单播发现使用的最大并发连接数。默认 10 个。
最小主节点数
对发现模块来说一个最重要的属性是 discovery.zen.minimum_master_nodes 属性。它允许设置构建集群所需的最小主节点(master node)候选节点数。这让我们避免了由于某些错误(如网络问题)而出现令人头疼的局面(即多个集群同名)。建议使用 discovery.zen.minimum_master_nodes 属性并设置为大于等于集群节点数的一半加 1。
Zen发现错误检测
ES 在工作中执行两个检测流程。第一个流程是由主节点向集群中其它节点发送 ping 请求来检测它们是否工作正常。第二个流程刚好相反,由每个节点向主节点发送请求来验证主节点是否正在运行并能履行其职责。然后由于网络速度很慢,或者节点部署在不同的地点,那么默认的配置也许就不合适了。因此 ES 的发现模块提供了一下可以修改的配置:
- discovery.zen.fd.ping_interval:设置节点向目标节点发送 ping 请求的频率,默认 1 秒。
- discovery.zen.fd.ping_timeout:设置节点等待 ping 请求响应的时长,默认 30 秒。如果节点使用率达到100%或者网速很慢,可以考虑增大该属性值。
- discovery.zen.fd.ping_retries:设置当目标节点被认为不可用之前 ping 请求的重试次数,默认为 3 次。如果网络丢包比较严重,可以考虑增大该属性值,或者修复网络。
亚马逊EC2发现
亚马逊商店除了交易商品外,还销售一些流行的服务,例如使用按量计费的模式销售存储空间或计算能力。这被称为 EC2 模式,亚马逊提供服务器实例,可以用来安装和使用 ES 集群(也可以安装其他许多东西,因为它们就相当于普通的 linux 服务器)。ES可以在 EC2 上工作,但由于环境的特性,一些功能在工作时会有所不同。其中之一就是发现模块,因为亚马逊 EC2 不支持多播发现。当然在单播模式下能正常工作,但失去了自动检测节点的能力,且在多数情况下不想失去这个功能。幸运的是还有一个替代方案:可以使用亚马逊 EC2 插件,即一个通过使用亚马逊 EC2 接口整合多播和单播发现的插件。
确保设置 EC2 实例时,开启了实例间的通讯(默认端口 9200 和 9300)。这对 ES 通讯和集群能正常工作至关重要。当然通讯设置依赖于 network.bind_host 和 network.publish_host(或 network.host)的配置。
EC2插件安装
同大多数插件一样,EC2插件的安装十分简单。安装时执行下面的命令:
| |
EC2插件的配置
为了使EC2发现能正常工作,需要对插件的以下属性进行配置:
- cluster.aws.access_key:亚马逊访问key,凭据值之一,可以在亚马逊配置面板中找到。
- cluster.aws.secret_key:亚马逊安全key,同 access_key 一样,可以在亚马逊 EC2 的配置面板中找到。 最后一件事是通知ES将要使用新的发现模式。可以通过关闭多播,并设置 discovery.type 属性值为 ec2 来实现。
可选的EC2发现配置
前面提到的设置已经足够运行EC2发现,但为了控制EC2发现插件的行为,ES还提供了一下配置项:
- cloud.aws.region:指定连接亚马逊web服务的趋于(region)。可以选择一个适合的实例所在区域。例如,爱尔兰可以选择eu-west-1。可选的区域有:eu-west-1、us-east-1、us-west-1 和 ap-southeast-1。
- cloud.aws.ec2.endpoint:不同于前面提到的设置指定区域,该属性设置得是一个 AWS 端点地址。如 ec2.eu-west-1.amazonaws.com。
- cloud.ec2.ping_timeout:确定当发送 ping 消息给一个节点时,响应的最长时间,默认为 3 秒。超过这个时间,没有响应的节点会被认为是已经宕机,并从集群中移除。在网络有问题或有很多 ec2 节点时可以增大这个值。 EC2节点扫描配置
该设置允许配置 EC2 集群构建过程中的一件非常重要的事情:在亚马逊网络上过滤可用 ES 节点的能力。ES EC2 插件提供了以下相关属性:
- discovery.ec2.host_type:指定了与集群中其它节点通信时使用的主机类型。可用选项有:private_ip(默认值,私有IP将被用来进行通信)、public_ip(公有IP将被用来进行通信)、private_dns(私有主机名将被用来进行通信)和 public_dns(公有主机名将被用来进行通信)
- discovery.ec2.tag:定义了一组设置。当启动亚马逊 EC2 实例时,可以定义描述该实例用途的标签,如客户名或环境类别,可以用这个标签来限制所发现的节点。如定义了一个名为 environment 的标签并赋值为 qa,配置时可以指定 discovery.ec2.tag.environment: qa。因而只有带着这个标签的实力上的节点才会被发现机制考虑。
- discovery.ec2.groups:定义了一个安全组列表。只有安全组内的节点才会被发现并加入到集群中。
- discovery.ec2.availability_zones:定义了一个可用地区列表。只有属于指定可用地区的节点才会被发现并加入到集群中。
- discovery.ec2.any_group:默认 true。设置为 false 会强制 EC2 发现插件只发现那些驻留在预定义好的安全组内的亚马逊实例上的ES节点。默认值只要求匹配一个组。
- cloud.node.auto_attributes:设为 true 时,上面的信息都能在配置分片部署时作为属性。
网关和恢复的配置
网关模块允许存储所欲 ES 正常工作时所需的数据。这意味着不仅存储了 Apache Lucene 的索引文件,还存储了所有的元数据(如索引的分配设置),以及每个索引的映射(mapping)信息。无论何时集群的状态发生了改变,例如当分配属性改变时,集群的状态就会通过网关模块被持久化。当集群启动时,之前保存的状态信息就会加载,并有网关模块使用。
注意在配置不同的节点和网关类型时,索引将使用所在节点所配置的那种网关类型。如果索引状态信息不应通过网关模块存储,需要显式设置索引网关类型为 none。
恢复的过程
恢复过程是 ES 为了正常运行而加载通过网关模块存储的数据的过程。无论何时,只要集群完全重启网关进程就开始生效,进而加载所有前面提到的那些相关信息:元数据、映射以及所有的索引。在分片恢复期间,ES在节点间拷贝数据,这些数据同样是 Lucene 索引、元数据和事务日志(用来恢复尚未索引的文档)。
ES允许配置应当何时通过网关模块来恢复实际的数据、元数据和映射。例如,在开始恢复过程前,需要等待集群拥有了一定数量的候选主节点或数据节点。注意,在恢复过程结束前,集群上执行的任何操作都是禁止的。这样是为了避免出现修改冲突。
可配置的属性
ES节点可以扮演不同的角色,可以作为数据节点(存储数据的节点),也可以作为主节点,其中主节点(一个集群中只有一个)除了处理查询请求外,还负责集群管理。当然节点也可以配置为既不是主节点也不是数据节点。这种情形下,该节点只作为执行用户查询的聚合节点。ES默认每个节点都是数据节点和候选主节点,但可以修改。取消某节点的主节点候选资格,在 elasticsearch.yml 文件中将 node.master 属性设为 false。让某节点成为非数据节点,在 elasticsearch.yml 文件中将 node.data 属性设为 false。
除此之外,还允许使用以下属性来控制网关模块的行为:
- gateway.recover_after_nodes:整数类型,表示要启动恢复过程集群中所需的节点数。如,属性值为 5 时,如果要启动恢复过程,集群中就至少要有 5 个节点(无论是候选主节点还是数据节点)存在。
- gateway.recover_after_data_nodes:允许设置当恢复过程启动时,集群中需要存在的数据节点数。
- gateway.recover_after_master_nodes:允许设置当恢复过程启动时,集群中需要存在的候选主节点数。
- gateway.recover_after_time:允许设置在条件满足后,启动恢复过程前需要等待的时间。 节点预期
还可以配置以下属性,从而可以强制启动恢复过程:
- gateway.expected_nodes:指定立即启动恢复过程需要集群中存在的节点数。如果不希望恢复过程被延迟,建议设置属性值为将用于构建集群的节点数(或者至少要接近这个数),因为这会保证最新的集群状态得到恢复。
- gateway.expected_data_nodes:指定了立即启动恢复过程需要集群中存在的数据节点数。
- gateway.expected_master_nodes:指定了立即启动恢复过程需要集群中存在的候选主节点数。
本地网关
随着 ES 0.20 版(以及 0.19 的某些版本)的发布,除了默认的本地类型,其它类型的网关都已弃用,并建议不再使用,因为在新版的 ES 中都会被移除。如果要避免重新索引全部数据,应该使用本地网关类型。
本地网关类型使用节点上可用的本地存储来保存元数据、映射和索引。为了使用本地网关,需要有充足的磁盘空间来容纳数据(数据全部写入磁盘,而非保存在内存缓存中)。
本地网关的持久化不同于其它当前存在(但是已经弃用)的网关类型。向本地网关的写操作是以同步的方式进行的,以确保在写入过程中没有数据丢失。
为了设置想要使用的网关类型,需要使用 gateway.type 属性,默认为 local。
备份本地网关
ES 0.90.5 及其之前版本不支持本地网关存储数据的自动备份。然而有时备份是有必要的,例如升级集群时,希望出错后可以回滚。为了实现这个目的,需要执行下面的操作:
- 停止向 ES 集群索引数据(意味着停止 river 或其它向 ES 发送数据的外部应用)
- 使用清空(Flush)API 清空所有尚未索引的数据
- 为分配在集群中的每个分片创建至少一个备份,至少可以保证一旦发生问题能找回数据。如果希望操作尽可能简单,可以拷贝集群中每个数据节点上的完整数据目录。
恢复配置
除了前面提到的可以使用网关类配置 ES 恢复过程的行为,但是除此之外,ES 还允许配置回复过程本身。分片分配时已经提到了一些恢复配置选项。
集群级的恢复配置
恢复配置大多针对的是集群级别,允许设置恢复模块使用的通用规则,可设置一下属性:
- indices.recovery_concurrent_streams:制定了从分片源回复分片时允许打开的并发流的数量,默认是 3。较大的值会给网络层带来更大的压力,但恢复过程会更快,这依赖于网络的使用情况和吞吐量。
- indices.recovery.max_bytes_per_sec:制定了在分片恢复过程中每秒传输数据的最大值,默认是 20MB。如果想取消数据传输限制,需要把这个属性设为 0。与并发流属性类似,该属性允许控制恢复过程中网络的使用。把它设为较大的值会带来较高的网络利用率,而且恢复过程会更快。
- indices.recovery.compress:默认值为 true,用来指定 ES 是否在恢复过程汇总压缩传输的数据。设为 false 可以降低 CPU 的压力,但同样会导致更多的网络数据传输。
- indices.recovery.file_chunk_size:指定从源分片向目标分片拷贝数据时数据块(chunk)的大小。默认值为 512KB,而如果将 indices.recovery.compress 属性设为 true 该值也会被压缩。
- indices.recovery.translog_ops:默认值为 1000,指定在恢复过程中分片间传输数据时,单个请求里最多可以传输多少行事务日志。
- indices.recovery.translog_size:指定从源分片拷贝事务日志时使用的数据块的大小。默认值为 512KB,且如果 indices.recovery.compress 属性设为 true,该值还会被压缩。
ES 0.90.0 以上的版本,indices.recovery.max_size_per_sec 属性可以使用,但是已经被弃用,现在建议使用 indices.recovery.max_bytes_per_sec 属性来代替。
所有提到过的设置都可以通过集群的更新 API 来更新,或者在 elasticsearch.yml 文件里设置。
索引级的恢复配置
还有一个可以在索引级设置得属性,即 index.recovery.initial_shards 属性,既可以在 elasticsearch.yml 文件里设置,也可以通过索引更新 API 设置。通常 ES 只有在特定数量的分片存在且能被分配时才会恢复一个特定的分片。该特定数量等于指定索引的分片数的 50% 加 1。通过 index.recovery.initial_shards 属性,可以改变 ES 的“特定数量”。该属性可以取以下值:
- quorum:这个值暗示需要总分片数 *50% 加 1 个分片存在且可分配。
- quorum-1:这个值暗示对于给定索引,需要总分片数 *50% 个分片存在且可以分配。
- full:这个值暗示对于给定索引,需要所有分片存在且可分配。
- full-1:这个值暗示对于给定索引,需要总分片数减 1 个分片存在且可分配。
- 整数值:这个值为任意整数,如1、2、5,代表需要存在且可分配的分片数量。例如,该值为 2 表示需要至少 2 个分片存在且可分配。 大多数情况下默认值对于部署来说已经够用了。
索引段统计
segments API简介
为了深入观察 Lucene 索引段,ES提供了 segments API,可以通过向 _segments REST 端点发送 HTTP GET 请求来访问它。例如,查看集群中所有索引的所有段信息,应当执行下面的命令:
| |
如果只想查看 mastering 索引的段信息,应当执行下面的命令:
| |
也可以同时看多个索引的段,只需执行下面的命令即可:
| |
segments API响应
segments API 调用的响应总是面向分片的,这是由于索引是由一个或多个分片(以及它们的副本)构成,每个分片就是一个物理上的 Lucene 索引。
假设有一个名为 mastering 的索引,并且里面已经索引了一些文档。创建索引时指定索引中只有一个分片且没有任何副本。
查看索引段:
| |
ES会返回大量可供分析的有用信息。 索引段列表每个段由下面的属性来表征:
- number:段编号,也是 JSON 对象名,所有其它的段相关信息都包含在该 JSON 对象里面(如 _0、_1 等)。
- generation:代表索引的代(generation),即一个告诉索引段由多“老”的字段。例如,代为 0 的索引段表示是最初创建的。
- num_docs:代表索引段内的文档数。
- deleted_docs:代表被标记为已删除的文档数,这些文档会在索引段合并过程中被删除。
- size:代表索引段在磁盘上的大小。
- size_in_bytes:代表索引段以字节为单位的大小。
- committed:true表示索引段已提交,反之没提交。
- search:表示索引段是否可以被 ES 搜索。
- version:代表该 Lucene 索引的版本号。注意:尽管一个给定版本的 ES 只使用一个 Lucene 版本,但也会出现不同索引段由不同版本的 Lucene 创建的情况。因而在升级 ES 时,新版 ES 可能刚好使用了不同版本的 Lucene。这种情况下,比较老的索引段在向新版合并时会被重写。
- compound:代表索引段时符合文件格式存储的(用单个文件存储改索引段的所有索引文件)
索引段信息的可视化
一个名为 SegmentSpy 的插件(https://github.com/polyfractal/elasticsearch-segmentspy )可以做 segments API 的可视化展示。
安装插件后,把浏览器只想 http://localhost:9200/_plugin/segmentspy/ ,并选择感兴趣的索引,会看到 segments 信息。
理解ES缓存
缓存在ES里扮演着重要角色,允许有效地存储过滤器并重用它们,使用父子功能、使用切面、以及基于索引字段的高效排序。
过滤器缓存
过滤器缓存是负责缓存查询中使用的过滤器的执行结果的。例如,下面这个查询:
| |
该查询会返回所有在 category 字段中包含 romance 词项的文档。可一个看到使用了 match_all 查询和一个过滤器。在该查询第一次执行以后,每个与该查询相同过滤的查询都会重用其结果,节省了宝贵的 I/O 和 CPU 资源。
过滤器缓存的种类
ES 中有两种类型的过滤器缓存:索引级和节点级,即可以选择配置索引级或节点级(默认选项)的过滤器缓存。由于不一定能预知给定索引会分配到哪里(实际上指索引的分片和副本),进而无法预测内存的使用,所以不建议使用索引级的过滤器缓存。
索引级过滤器缓存的配置
ES允许使用下面的属性配置索引级过滤器缓存的行为:
- index.cache.filter.type:设置缓存的类型,可以使用 resident、soft、weak 或 node(默认值)。在 resident 缓存中的记录不能被 JVM 移除,除非想移除它们(通过使用 API,设置最大缓存值,或者设置过期时间),并且也是因为这个原因而推荐使用它(填充过滤器缓存代价很高)。内存吃紧时,JVM 可以清除 soft 和 weak 类型的缓存,区别是在清理内存时,JVM 会优先清除 weak 引用对象,然后才是 soft 引用对象。最后的 node 属性代表缓存将在节点级控制。
- index.cache.filter.max_size:指定能存储到缓存中的最大记录数(默认是 -1,代表无限制)。需要注意这个设置不是应用在整个索引上,而是应用于指定索引的某个分片的某个索引段上,所以内存的使用量会因索引的分片数和副本数以及索引中段数的不同而不同。通常来说,结合 soft 类型使用默认无限制的过滤器缓存就足够了。谨记慎用某些查询以保证缓存的可重用性。
- index.cache.filter.expire:指定过滤器缓存中记录的过期时间,默认是 -1,代表永不过期。希望对过滤器缓存设置超时时长,可以设置最大空闲时间。
节点级的过滤器缓存设置
节点级过滤器缓存是默认的缓存类型,应用于分配到给定节点上的所有分片(设置 index.cache.filter.type 属性为 node,或者不设置这个属性)。ES 允许使用 indices.cache.filter.size 属性来配置这个缓存的大小,既可以使用百分数,如 20%(默认值),也可以使用确定的数值,如 1024mb。如果使用百分数,ES 会按当前节点的最大堆内存的百分比来计算内存使用量。
节点级过滤器缓存是 LRU 类型(最近最少使用)缓存,这意味着为了给新纪录腾出空间,在删除缓存记录时,使用次数最少得那些记录会被删除。
字段数据缓存
字段数据缓存在查询涉及切面计算或给予字段数据排序时使用。ES所做的事加载相关字段的全部数据到内存中,从而使 ES 能够快速地基于文档访问这些值。注意,从硬件资源的角度,构建字段数据缓存代价通常很高,因为字段的所有数据都需要加载到内存中,需要消耗 I/O 操作和 CPU 资源。
对于每个用来排序或做切面计算的字段,其数据都需要加载到内存中:所有的词项。 这样做的代价非常高昂,尤其是应用于那些高基数的字段(拥有大量不同词项的字段)时。
索引级字段数据缓存配置
也可以使用索引级别的字段数据缓存,但与索引级过滤器缓存类似,并不建议使用它。原因就是很难预测哪个分片或索引会分配到哪个节点,因此无法预估缓存每个索引需要的内存大小,而这会带来内存使用方面的问题。
当然,如果你仍需要使用。可以通过设置 index.fielddata.cache.type 属性为 resident 或 soft 来实现。跟描述过滤器缓存时讨论过的情形类似,除非想删除,否则 resident 类型的缓存是不能被 JVM 删除的。推荐在使用索引级字段数据缓存时使用 resident 类型的缓存,因为重建字段数据缓存代价很高,并且会影响 ES 的查询性能,而 soft 类型的字段数据缓存在缺少内存时会被 JVM 清除掉。
节点级字段数据缓存配置
ES 0.90.0 版本中,如果没有修改过配置,节点级字段数据缓存是默认的字段数据缓存类型,可以使用下列属性进行配置:
- index.fielddata.cache.size:制定了字段数据缓存的最大值,既可以是一个百分比的值,如 20%,也可以是一个绝对的内存大小,如 10GB。百分比的话 ES 会按当前节点的最大堆内存的百分比来计算内存使用量。
- index.fielddata.cache.expire:指定字段数据缓存中记录的过期时间,默认为 -1,表示缓存中的记录永不过期。如果要设置字段数据缓存过期时长,可以设置最大空闲时间。
过滤
ES还允许选择性地将某些字段值加载到字段数据缓存中。在某些情况下非常有用,如基于字段数据排序或切面计算时。ES 支持两种类型三种形式的字段数据过滤,即基于词频、基于正则表达式,以及基于两者的组合。
某些场景中字段数据过滤非常有用,例如从切面计算的结果中排除那些低频词项。具体来说,索引中某些词项存在拼写错误,而这些词项一定是低基数词项,不想基于它们做切面计算,可以从数据里删除、修正或使用过滤器从字段数据缓存中删除。这样不仅返回结果得到了过滤,同时因为更少的数据存储在内存中,降低了字段数据缓存的总量。
添加字段数据过滤信息
引入字段数据缓存过滤信息,需要在映射文件的字段定义部分额外添加两个对象:fielddata对象及其子对象 filter。扩展后的字段定义(以某个抽象的 tag 字段为例):
| |
基于词频过滤
基于磁盘过滤的结果是只加载那些频率高于指定最小值且低于指定最大值的词项,其中词频最小值和最大值分别由 min 和 max 参数指定。词频的频率范围不是针对整个索引的,而是针对索引段的。同一个词项在段级和索引级的频率分布往往不一样,这个特性非常重要。参数 min 和 max 可以是百分比的形式,也可以是一个绝对词频数。
另外,min_segment_size 属性指定了在构建字段数据缓存时,索引段应满足的最小文档数,小于该文档数的索引段不会被考虑。
例如,指向保存来自容量不小于 100 的索引段,且词频在段中介于 1% 和 20% 之间的词项到字段数据缓存中,可以进行如下字段映射:
Expand/Collapse Code Block
| |
基于正则表达式过滤
只有匹配特定正则表达式的词项会加载到字段数据缓存中。如果只想缓存来自 tag 字段的数据,如 Twitter 标签(以字符 # 开头),应配置映射:
| |
基于正则表达式和词频过滤
组合基于词频和基于正则表达式的过滤方法。如果想把 tag 字段的数据保存到字段数据缓存中,但是只缓存那些以字符 # 开头,且所在索引段至少有 100 个文档,以及词项在段中介于 1% 和 20% 之间的词项,映射如下:
Expand/Collapse Code Block
| |
字段数据缓存虽然不是在索引期间构建的,但却可以在查询期间重建,可以在运行时改变过滤行为,并具体公国使用映射 API 更新 fielddata 配置来实现。注意,改变字段数据缓存过滤设置后清空缓存,可以通过使用清理缓存 API 来实现。
一个过滤的例子
本小节刚开始的例子,排除切面计算结果中的低频词项。低词频项指的是词频最低的那 50% 的词项。为了验证过滤效果,用如下命令创建 books 索引:
Expand/Collapse Code Block
| |
然后使用 bulk API 索引一些文档:
| |
运行查询验证简单的词项切面计算(切面计算使用到了字段数据缓存):
| |
响应中,切面计算只涉及了词项 one,其它 4 个被忽略了。讲定词项 four 存在拼写错误,就已经达到目的了。
清除缓存
在改变字段数据过滤以后需要清除缓存,这点很关键。当改变一些明确设定了缓存键值的查询时也需要清除缓存,而使用 ES 的 _cache rest 端点就可以做到这点。
单一索引缓存、多索引缓存和全部缓存的清除
清空全部缓存的最简单做法:
| |
清空一个或多个索引的缓存。例如清除 mastering 索引的缓存:
| |
同时清除 mastering 和 books 索引的缓存:
| |
清除特定缓存
也可以只清楚一种指定类型的缓存。以下列出可以被单独清除的缓存类型:
- filter:可以通过设置 filter 参数为 true 来清除。反之为了避免清除这种缓存需要设置为 false。
- filter_data:可以通过设置 filter_data 参数为 true 来清除。反之为了避免清除这种缓存需要设置为 false。
- bloom:可以通过设置 bloom 参数为 true 来清除 bloom 缓存(如果某种倒排索引格式使用了 bloom filter ,则可能会使用这种缓存) 例如,清除 mastering 索引的字段数据缓存,并保留 filter 缓存和 bloom 缓存,则可以执行:
| |
清除字段相关的缓存
除了清除全部或特定的缓存,还可以清除指定字段的缓存。在需要的请求中增加 fields 参数,参数值为索要清除缓存的相关字段名,多个字段名用逗号分隔。
例如,清除 mastering 索引里 title 和 price 字段的缓存:
| |
小结
本章,学习了如何选择合适的目录来使 ES 以最高效的方式访问底层 I/O 系统,也学习了如何使用多播和单播模式类配置节点的发现模块。然后讨论了网关模块,它能控制进行集群恢复的时机,同时也讨论了恢复模块及其配置。此外,还学习了如何分析 ES 返回的索引段信息。最后了解了 ES 缓存的工作机制,如何调整它以及如何控制字段数据缓存的构建方式。
14.6.6 - 深入理解ES-06故障处理
14.6.7 - 深入理解ES-07改善用户搜索体验
14.6.8 - 深入理解ES-08 ES Java API
14.6.9 - 深入理解ES-09开发ES插件
15 - APM
Introduction
APM
15.1 - Skywalking基础使用
Skywalking概述
什么是Skywalking
概述
Skywalking主要概念包括:
- 服务(Service)
- 端点(EndPoint)
- 实例(Instance)

端点就是服务暴露的端口如/usr/queryAll
安装
安装ES
替换默认的h2存储,因为h2存储在内存中。
1、下载并解压ES安装包
| |
2、修改Linux系统的限制配置,将文件创建数修改为65536个。 1)修改系统中允许应用最多创建多少文件等的限制权限。Linux默认一般限制创建的文件数为65535个。但是ES至少需要65536的文件创建数的权限。
2)修改系统中允许用户启动的进程开启多少个线程。Linux默认root用户可以开启任意数量的线程,其他用户的进程可以开启1024个线程。必须修改限制数为4096+。因为ES至少需要4096个线程池预备。
| |
3、修改系统控制权限,ES需要开辟一个65536字节以上空间的虚拟缓存。Linux不允许任何用户和应用程序直接开辟这么的虚拟内存。
| |
4、创建一个用户,用于ES启动 因为ES在5.x版本后,强制在Linux中不能使用root用户启动ES进程。
| |
5、启动ES
| |
6、测试启动 ES默认不支持跨域访问,在不修改配置的情况下只能在本机上访问测试是否成功
| |
安装Skywalking
两个步骤:
1、安装后端服务
2、安装UI
| |
修改Skywalking存储的数据源配置
| |
webapp配置
| |
启动程序
| |
日志
| |
浏览器访问skywalking网页页面
基础
agent使用
agent探针在java中使用java agent技术实现的,不需要更改任何代码,java agent会通过虚拟机接口来在运行期更改代码
Agent探针支持JDK1.6-12的版本,Agent探针所有的文件在Skywalking的agent文件夹下。文件目录如下:
| |
部分插件在使用上会影响整体的性能或者由于版权问题放置于可选插件包中,不会直接加载,如果需要使用,将可选插件中的jar包拷贝到plugins包下
| |
修改agent.service_name
| |
配置含义是可以读到SW_AGENT_NAME配置属性,如果该配置没有指定,那么默认名称为Your_xx,这里替换成skywalking_tomcat 然后将tomcat重启
| |
Linux下tomcat7和8中使用
1、要使用Skywalking监控Tomcat中的应用,需要先准备一个Spring mvc项目,这里准备好一个打包好的文件:skywalking_springmvc-1.0-SNAPSHOT.war
内容为一个简单的hello world controller接口.
2、将war包上传至 xx/apache-tomcat-xx/webapps/下。编辑xx/apache-tomcat-xx/bin/catalina.sh文件,在文件顶部添加:
| |
修改tomcat启动端口
| |
启动项目,访问接口,然后访问skywalking UI页面,即可查询各项指标
Windows下tomcat7和8中使用
windows下只需要修改 tomcat目录下 bin/catalina.bat文件的第一行为:
| |
Spring Boot中使用
Skywalking与spring boot集成提供了完善的支持
1、首先复制一份agent,防止与tomcat使用时冲突
| |
修改配置的应用名为 skywalking_boot(同上,略) 2、同上准备web应用 skywalking_springboot.jar,提供一个正常和异常controller接口
将文件上传至 xx 目录下。
3、使用命令启动spring boot项目
| |
访问接口,然后访问skywalking UI页面,即可查询各项指标
RocketBot的使用
Skywalking的监控UI页面称为RocketBot,可以通过修改webapp/webapp.yml来更改端口:
| |
Skywalking高级
Rpc调用监控
MySQL调用监控
启动docker
| |
使用docker启动MySQL
| |
Skywalking常用插件
配置覆盖
系统配置
使用skywalking. +配置文件中的配置名作为系统配置项来进行覆盖
1、为什么添加前缀
agent的系统配置和环境与目标应用共享,所以加上前缀可以有效的避免冲突
2、案例
通过如下进行agent.service_name的覆盖
| |
探针配置
Add the properties after the agent path in JVM arguments
| |
1、案例 通过如下进行agent.service_name覆盖
| |
2、特殊字符 如果配置中包含分隔符(,或者=),就必须使用引号包裹起来
| |
系统环境变量
1、案例
由于agent.service_name配置项如下所示:
| |
可以在环境变量中设置SW_AGENT_NAME的值来制定服务名
覆盖优先级
探针配置 > 系统配置 > 系统环境变量配置 > 配置文件中的值
所以我们的启动命令可以修改为:
| |
或
| |
过滤指定端点
1、将skywalking_plugins.jar上传至/usr/local/wkywalking目录下
2、将agent中的/agent/optional-plugins/apm-trace-ignore-plugin-6.4.0.jar插件拷贝到plugins目录中
| |
3、启动skywalk_plugins应用
| |
这里添加-Dskywalking.trace.ignore_path=/exclude参数来标识需要过滤哪些请求,支持Ant Path表达式: /path/*, /path/**, /path/?
- ?匹配任何单字符
- *匹配0或者任意数量的字符
告警功能
告警功能简介
Skywalking每隔一段时间根据收集到的链路追踪的数据和配置的告警规则(如服务响应时间、服务响应时间百分比等),判断如果达到阈值则发送响应的告警信息。发送告警信息是通过调用webhook接口完成,具体的webhook接口可以使用者自行定义,从而开发者可以在指定的webhook接口编写各种告警方式,比如邮件、短信等。告警的信息也可以在RocketBot中查看。
以下是默认的告警规则配置,位于skywalking安装目录下的config文件夹下alarm-settings.yml文件中:
| |
满足规则的告警信息将调用通过webhooks配置的接口
Skywalking原理
Java agent原理
Javaagent是什么?
Java agent是java命令的一个参数。参数javaagent可以用于指定一个jar包。
1、这个jar包的MANIFEST.MF文件必须指定Premain-Class项。
2、Premain-Class指定的那个类必须实现premain()方法
当java虚拟机启动时,在执行main函数之前,JVM会先运行-javaagent所指定jar包内Premain-Class这个类的premain方法。
如何使用java agent?
步骤:
- 定义一个MANIFEST.MF文件,必须包含Premain-Class选项,通常也会加入Can-Redefine-Classes和Can-Retransform-Class选项
- 创建一个Premain-Class指定的类,类中包含premain方法,方法逻辑由用户自己指定
- 将premain的类和MANIFEST.MF文件打成jar包
- 使用参数-javaagent:jar包路径 启动要代理的方法
搭建java agent工程
PreMainAgent.java
| |
优先调用两个参数的方法
MANIFEST.MF
在maven中添加插件 maven-assembly-plugin
Expand/Collapse Code Block
| |
统计方法调用时间
Skywalking中对每个调用的市场都进行了统计。这一小节中我们会使用ByteBuddy和Java agent技术来统计方法的调用时长
Byte Buddy是开源的、基于Apache 2.0许可证的库,它致力于解决字节码操作和Instrumentation API的复杂性。Byte Buddy所声称的目标是将显式的字节码操作隐藏在一个类型安全的领域特定语言背后。通过使用Byte Buddy,任何熟悉Java编程语言的人都有望非常容易地进行字节码操作。Byte Buddy提供了额外的API来声称Java agent,可以轻松的增强我们已有的代码。
添加依赖:
| |
PreMainAgent.java
| |
MyInterceptor.java
| |
Open Tracing介绍
Open Tracing通过提供平台无关、厂商无关API,使得开发人员能够方便的添加/更换追踪系统的实现。OpenTracing 最核心的概念就是 Trace
Trace的概念
广义上,一个trace代表了一个事务或者流程在(分布式)系统中的执行过程。在OpenTracing标准中,trace是多个span组成的一个有向无环图(DAG),每一个span代表trace中被明明并计时的连续性的执行片段。

例如客户端发起的一次请求,就可以认为是一个trace。将上面的图通过Open Tracing的语义修改完之后做可视化,得到下面的图:

图中每一个色块其实就是一个span.
Span的概念
每一个Span代表系统中具有开始时间和执行市场的逻辑运行单元。span之间通过嵌套或者顺序排列简历逻辑因果关系。
Span里面的信息包括:操作的名字,开始时间和结束时间,可以附带多个key:value构成的Tags(key必须是String,value可以是String/bool/数字),还可以附带Logs信息(不一定所有的实现都支持)也是key:value形式。
一个Span可以和一个或多个Span间存在因果关系。OpenTracing定义了两种关系:childOf和FollowsFrom。这两种引用类型代表了子节点和父节点间的直接因果关系。未来,OpenTracing将支持非因果关系的span引用关系。(例如:多个span被批量处理,span在同一个队列等)
Log的概念
每个span可以进行多次Logs操作,每一次Logs操作,都需要一个带时间戳的时间名称,以及可选的任意大小的存储结构。
Tags的概念
每个span可以有多个键值对(key:value)形式的Tags,Tags是没有时间戳的,支持简单的对span进行注解和补充。

15.2 - Skywalking源码分析2-启动流程
概述

初始化配置
加载配置信息
- /config/agent.config
- 系统环境变量
- Agent参数
- 优先级:自下而上
将配置信息映射到Config类
根据配置信息重新指定日志解析器
检查agent名称和后端地址是否配置
标记配置加载完成
加载插件
M: 插件配置读取
可以借鉴读取配置
按行加载键值对,并剔除指定不需要的插件
- SkyWalkingAgent.java
- PluginFinder.java
- PluginBootstrap.java
- PluginCfg.java
- PluginBootstrap.java
- PluginFinder.java
插件定义体系
插件定义: XxxInstrumentation
- 拦截实例方法/构造器:ClassInstanceMethodsEnhancePluginDefine
- 拦截静态方法:ClassStaticMethodsEnhancePluginDefine
- AbstractClassEnhancePluginDefine 插件顶级父类
- 要拦截的类:enhanceClass()
- 要拦截的方法:getXxxInterceptorPoints()
目标类匹配
- ClassMatch
- 按类名匹配:NameMatch
- 间接匹配:IndirectMatch
- PrefixMatch
- MethodAnnotationMatch
拦截器定义
- beforeMethod
- afterMethod
- handleMethodException
插件声明
- resouces/skywalking-plugin.def
- 插件名称-插件定义
加载流程
PluginBootstrap实例化所有插件
PluginFinder分类插件
- 命名插件 - NameMatch
- 间接匹配插件 - IndirectMatch
- JDK 类库插件
定制Agent
创建ByteBuddy实例
指定ByteBuddy要忽略的类
将必要的类注入到BootstrapClassLoader中
为什么注入classes到bootstrap加载器中
| |

解决JDK模块系统的跨模块类访问
根据配置决定是否将修改后的字节码保存到磁盘/内存中
细节定制
指定 ByteBuddy要拦截的类
指定做字节码增强的工具
指定做字节码增强的模式
redefine和restransform的区别在于是否保留修改前的内容
- redefine 覆盖
- restransform 保留
注册监听器
将Agent安装到Instrumentation
什么是Synthetic
Synthetic关键字:
合成的,Java编译器在编译阶段自动生成的[构造]
JLS:所有存在于字节码文件中,但是不存在于源代码文件的[构造],都应该被synthetic关键字标注
[构造] => Constructs => Field/Method/Constructor
内部类的私有属性、私有构造方法,都能被外部类直接访问不报错,就是因为编译期新生成了方法或者属性,这就是synthetic
getModifiers()的值为4096的话,就代表这个是synthetic
用js简单说明:java编译器帮我们自动做了var that = this操作
讲解环境:JDK11之前的版本
NBAC(Nested Based Access Control)
编译环境JDK1.8
| |
内部类里面存在同一个方法的不同调用方法呈现不同结果的情况
- 直接调用 - 可以
- 反射调用 - 不可以
| |
NBAC消除了这个问题,将pom插件配置改成JDK11(1.8改成1.11)就没有这个问题 原理:
阅读JDK11的Class.java源码,观察getNestHost等方法
Inner => nestHost = Outer.class
Outer => nestMembers = {Inner.class}
nestMembers => nestMates
| |
加载服务
| |
服务组织
- 服务需要实现 BootService 接口
- 如果服务只有一种实现,直接创建一个类即可
- 如果服务有多种实现
- 默认实现需要使用 @DefaultImplementor
- 覆盖实现需要使用 @OverrideImplementor
加载流程
- SPI加载所有 BootService 的实现
- 根据服务的实现模式进行服务的筛选
- 两个注解都没有的服务实现直接加入集合
- 对于 @DefaultImplementor 直接加入集合
- 对于 @OverrideImplementor
- value指向的服务有 @DefaultImplementor 则覆盖掉
- value指向的服务没有 @OverrideImplementor 则报错
插件工作原理
组件版本识别技术(Witness机制)
- witnessClasses
- witnessMethods

可观察 AbstractClassEnhancePluginDefine.java的 witnessClasses()或witnessMethods方法的实现
举例:查看spring官方文档,对比新增class
参考spring 3.2.9: https://docs.spring.io/spring-framework/docs/3.2.9.RELEASE/javadoc-api/ 参考spring 4: https://docs.spring.io/spring-framework/docs/4.3.30.RELEASE/javadoc-api/ 参考spring 5: https://docs.spring.io/spring-framework/docs/5.2.16.RELEASE/javadoc-api/
工作流程
校验 TypeDescription 插件是否可用
字节码增强流程
| |
静态方法插桩
- 要修改原方法入参
- 是 JDK 库核心类
- 不是 JDK 库核心类
- 实例化插件中定义的 Interceptor
- 调用 beforeMethod()
- 可以修改原方法入参
- 调用原方法
- 调用时可以传参
- 对于异常,调用 handleMethodException
- 调用 afterMethod()
- 不修改原方法入参
- 是 JDK 库核心类
- 不是 JDK 库核心类
- 实例化插件中定义的 Interceptor
- 调用 beforeMethod()
- 调用原方法
- 调用时不能传参
- 对于异常,调用 handleMethodException
- 调用 afterMethod()
构造器和实例方法插桩
- 构造器
- 是 JDK 库核心类
- 不是 JDK 库核心类
- 只能在拦截的构造器原本逻辑执行完成以后再执行 onConstruct()
- 实例方法
- 参考静态方法
将记录状态的上下文 EnhanceContext 设置为 [已增强]
插件拦截器加载流程
双亲委派机制
Agentxxx必须使用目标类的类加载器加载,才能操作目标类。

运行时插件效果的字节码
借助工具:https://github.com/zifeihan/friday
| |
// 不能这么写,会死循环,因为原方法被重命名,如sayHello()被会改成sayHelloXxxx(),新方法sayHello()则是修改字节码后的的方法,method指代的是修改后的sayHello()方法,这里需要调用sayHelloXxx()方法
注册关闭钩子
15.3 - 字节码增强技术-概念
ASM
Cglib就是基于ASM
IDEA插件 ASM Bytecode Outline:https://plugins.jetbrains.com/plugin/5918-asm-bytecode-outline/
ASM和Javassist对比及应用
- javassist是基于源码级别的API比基于字节码的ASM简单;
- 基于javassist开发,不需要了解字节码的知识,而且其封装的一些工具类可以简单实现一些高级功能, 比如HotSwaper
- ASM比javassist性能更快, 灵活性也较高
- javassist提供的动态代理接口最慢, 比JDK自带的还慢
- 监控方法, 采集方法运行时的入参, 出参和异常信息.
JavaAssist
ASM虽然可以达到修改字节码的效果,但是代码实现上更偏底层,是一个个虚拟机指令的组合,不好理解、记忆,和Java语言的编程习惯有较大差距。
利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。
- ClassPool:保存CtClass的池子,通过classPool.get(类全路径名)来获取CtClass
- CtClass:编译时类信息,它是一个class文件在代码中的抽象表现形式
- CtMethod:对应类中的方法
- CtField:对应类中的属性、变量
上面ASM和JavaAssist的Demo,都有一个共同点:两者例子中的目标类都没有被提前加载到JVM中,如果只能在类加载前对类中字节码进行修改,那将失去其存在意义,毕竟大部分运行的Java系统,都是在运行状态的线上系统。
JVM是不允许在运行时动态重载一个类的
Instrumentation
instrument是JVM提供的一个可以修改已加载类的类库。它需要依赖JVMTI的Attach API机制实现,在JDK 1.6之后,instrument支持了在运行时对类定义的修改。要使用instrument的类修改功能,我们需要实现它提供的ClassFileTransformer接口,定义一个类文件转换器。接口中的transform()方法会在类文件被加载时调用
先看下其关键方法
| |
Agent
光有Instrumentation接口还不够,如何将其注入到一个正在运行JVM的进程中去呢?我们还需要自定义一个Agent,借助Agent的能力将Instrumentation注入到运行的JVM中。
Agent是JVMTI的一种实现,Agent有两种启动方式
- 一是随Java进程启动而启动,经常见到的java -agentlib就是这种方式;
- 二是运行时载入,通过attach API,将模块(jar包)动态地Attach到指定进程id的Java进程内
PremainClass随JVM进程启动
-javaagent方式
以Agent+JavaAssist的方式实现了零侵入方式的AOP,其原理就是JVM会优先调用PreMain方法(即Agent中的方法),后面才会调用Main方法。
但是缺点也是显而易见的,Agent必须随着JVM进程启动而加载的方式,不够灵活
AgentClass以Attach方法注入Agent
随着进程启动的Premain方式的Agent更偏向是一种初始化加载时的修改方式,而Attach API的loadAgent()方法,能够将打包好的Agent jar包动态Attach到目标JVM上,是一种运行时注入Agent、修改字节码的方式
市面上诸如Arthas、Btrace这种JVM监控工具即是基于这种思路实现
ByteBuddy
ByteBuddy是一个可以在运行时动态生成java class的类库。
- 基于ASM的代码修改和生成工具
- runtime期动态生成和修改
- easy use无需理解字节码,简洁的代码风格
Reference
16 - Container
Introduction
Docker、Docker Compose等
16.1 - 01.docker基础版
概述

安装卸载
安装
CentOS安装:https://docs.docker.com/engine/install/centos/

mac安装:https://yeasy.gitbook.io/docker_practice/install/mac
| |
卸载
| |
run的流程

底层原理
Docker是怎么工作的?
Docker是一个Client-Server结构的系统,Docker的守护进程运行在主机上。通过socket从客户端访问
DockerServer接收到Docker-Client的指令,就会执行这个命令

Docker为什么比VM快?
1、Docker有着比虚拟机更少的抽象层
2、Docker利用的是宿主机的内核,vm需要Guest OS
常用命令
官方命令文档:https://docs.docker.com/engine/reference/commandline/info/
Demo
| |
帮助命令
| |
镜像命令
查看镜像 images
| |
列出镜像 image ls
| |
搜索镜像 search
| |
下载镜像 pull
| |
删除镜像
注意ID可用短ID,保证不重复即可,完整的ID也称为长ID
| |
镜像体积 system df
| |
利用commit理解镜像构成
参考:https://yeasy.gitbook.io/docker_practice/image/commit
入侵后保存现场等.
不要用作定制镜像,应使用Dockerfile完成
| |
慎用:简单修改文件会发现大量无关内容被改动,导致镜像臃肿。生成的镜像也被称为 黑箱镜像
容器命令
说明:有了镜像才能创建容器,下载一个centos镜像来测试学习
| |
新建容器并启动 run
| |
exec
在运行的容器中执行命令
| |
列出所有的运行的容器 ps
| |
退出容器
| |
删除容器
| |
启动和停止容器的操作
| |
常用其他命令
后台启动容器
| |
查看日志
| |
查看容器中进程信息
| |
查看镜像的元数据
| |
进入当前正在运行的容器
| |
从容器内拷贝文件到主机上
| |
查看映射端口配置
| |
练习
部署Nginx
| |
端口暴露

思考问题:每次改动Nginx配置文件,都需要进入容器内部,十分麻烦。需要可以在容器外部提供一个映射路径,达到在容器外部修改文件名,容器内部可以自动修改?-v 数据卷
部署Tomcat
| |
思考问题:部署项目,每次进入容器十分麻烦。在容器外部提供一个映射路径,webapps,在外部放置项目,自动同步到内部就行了
部署es+kibana
https://hub.docker.com/_/elasticsearch
es暴露接口多
es十分消耗内存
es的数据一般需要防止
| |

可视化
portainer(先用这个)
1 2 3docker run -d -p 8088:9000 --restart=always -v /var/run/docker.sock:/var/run/docker.sock --privileged=true portainer/portainerRancher(CI/CD再用)
什么是portainer
Docker图形化界面管理工具,提供一个后台面板供我们操作
Docker镜像讲解
镜像是什么
Docker镜像加载原理
UnionFS(联合文件系统)
UnionFS(联合文件系统):Union文件系统(UnionFS)是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以讲不同目录挂在到同一个虚拟文件系统下(unite several directories into a single virtual filesystem)。Union文件系统是Docker镜像的基础。镜像可以通过分层来进行继承,基于镜像(没有父镜像),可以制作各种具体的应用镜像。
特性:一次同时加载多个文件系统,但从外面看起来,只能看到一个文件系统,联合加载会把各层文件系统叠加起来,这样最终的文件系统会包含所有底层的文件和目录。
下载时一层层的记录就是这个。。
Docker镜像加载原理
docker的镜像实际上由一层一层的文件系统组成,这种层级的文件系统UnionFS。
bootfs(boot file system)主要包含bootloader和kernel,bootloader主要是引导加载kernel,Linux刚启动时会加载bootfs文件系统,在Docker镜像的最底层是bootfs。这一层与我们典型的Linux/Unix系统十一样的,包含boot加载器和内核。当boot加载完成之后整个内核都在内存中了,此时内存的使用权已由bootfs转交给内核,此时系统也会卸载bootfs。
rootfs(root file system),在bootfs之上。包含的就是典型Linux系统中的/dev, /proc, /bin, /etc等标准目录和文件。rootfs就是各种不同的操作系统发行版,比如Ubuntu,Centos等。
平时安装的CentOS都是好几个G,为什么Docker这里才200M?
对于一个精简的OS,rootfs可是很小,只需要包含最基本的命令、工具和程序库就可以了,因为底层直接用Host的kernel,自己只需要提供rootfs就可以了。由此可见对于不同的linux发行版,bootfs基本是一致的,因此不同的发行版可以共用bootfs。
分层理解
分层的镜像
| |
拉去镜像时,显示6层

查看Layers详情时,刚好6层

特点
Docker镜像都是只读的,当容器启动时,一个新的可写层被加载到镜像的顶部!
这一层就是我们通常说的容器层,容器之下的都叫镜像层
commit镜像
| |
实战测试
| |

容器数据卷
什么是容器数据卷
如果数据都在容器中,那么我们容器删除,数据就会丢失!需求:数据持久化
MySQL,容器删了,删除跑路!需求:MySQL数据可以存储在本地
容器之间可以有一个数据共享的技术!Docker容器中产生的数据,同步到本地!
这就是卷技术,目录的挂在,将我们容器内的目录,挂在到Linux上面!

总结一句话:容器的持久化和同步操作!容器间也是可以数据共享的!
使用数据卷
方式一:直接使用命令来挂载 -v
| |

容器内创建的文件,会在容器外创建,反之亦然。
实战:安装MySQL
具名和匿名挂载
| |
所有docker容器内的卷,没有指定目录的情况下都是在
| |
大多数情况下使用具名挂载
| |
拓展:
| |
初识DockerFile
DockerFile就是用来构建docker镜像的构建文件!命令脚本
通过脚本可以生成镜像,镜像是一层一层的,脚本一个个的命令,每个命令都是一层
| |
通过dockerfile这种方式也能卷挂载
数据卷容器
| |
多个MySQL实现数据共享
| |
结论: 容器之间配置信息的传递,数据卷容器的声明周期一直持续到没有容器使用为止。
但是一旦持久化到了本地,本地的数据时不会删除的。
Dockerfile
Dockerfile就是用来构建docker镜像的构建文件
构建步骤:
1、编写一个dockerfile文件
2、docker build 构建成为一个镜像
3、docker run 运行镜像
4、docker push 发布镜像(DockerHub、阿里云镜像仓库)
DockerFile构建过程
基础知识:
1、每个保留关键字(指令)
2、从上到下顺序执行
3、#表示注释
4、每一个指令都会创建一个新的镜像层,并提交

DockerFile的指令

| |
Dockerfile定制镜像
参考:https://yeasy.gitbook.io/docker_practice/image/build
Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及行首 # 进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。
FROM指定基础镜像
官方镜像都是基础包,很多功能没有,通常会自己搭建自己的镜像
除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。
| |
RUN执行命令
两种格式
- shell格式:RUN <命令>
| |
- exec格式:RUN ["可执行文件", "参数1", "参数2"]
| |
Dockerfile 中每一个指令都会建立一层,上述非常臃肿、非常多层的镜像,正确写法
| |
还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。这是很重要的一步,我们之前说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。
构建镜像
命令格式
docker build [选项] <上下文路径/URL/->
在 Dockerfile 文件所在路径执行
| |
默认Dockerfile 的文件名为但并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f ../Dockerfile.php 参数指定某个文件作为 Dockerfile。
镜像构建上下文(Context)
构建命令最后有个“.”,指定上下文路径。
Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。
虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。
当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?
这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。
一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。
其它docker build的用法
直接用 Git repo 进行构建
| |
这行命令指定了构建所需的 Git repo,并且指定分支为 master,构建目录为 /amd64/hello-world/,然后 Docker 就会自己去 git clone 这个项目、切换到指定分支、并进入到指定目录后开始构建。
用给定的 tar 压缩包构建
| |
如果所给出的 URL 不是个 Git repo,而是个 tar 压缩包,那么 Docker 引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。
从标准输入中读取 Dockerfile 进行构建
| |
如果标准输入传入的是文本文件,则将其视为 Dockerfile,并开始构建。这种形式由于直接从标准输入中读取 Dockerfile 的内容,它没有上下文,因此不可以像其他方法那样可以将本地文件 COPY 进镜像之类的事情。
从标准输入中读取上下文压缩包进行构建
| |
如果发现标准输入的文件格式是 gzip、bzip2 以及 xz 的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。
Dockerfile指令详解
参考:https://yeasy.gitbook.io/docker_practice/image/dockerfile
COPY 复制文件
两种格式:类似于命令行、类似于函数调用
| |
使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。
在使用该指令的时候还可以加上 --chown=<user>:<group> 选项来改变文件的所属用户及所属组。
| |
ADD 更高级的复制文件
不太常用的命令。
在 COPY 和 ADD 指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用 COPY 指令,仅在需要自动解压缩的场合使用 ADD。
CMD 容器启动命令
ENTRYPOINT 入口点
可以在原来的命令后面增加参数
ENV 设置环境变量
格式有两种:
| |
ARG 构建参数
Dockerfile 中的 ARG 指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 docker build 中用 --build-arg <参数名>=<值> 来覆盖。
灵活的使用 ARG 指令,能够在不修改 Dockerfile 的情况下,构建出不同的镜像。
ARG 指令有生效范围,如果在 FROM 指令之前指定,那么只能用于 FROM 指令中。但是多个FROM中可以生效,各个阶段必须重新指定
VOLUME 定义匿名卷
EXPOSE 暴露端口
WORKDIR 指定工作目录
| |
如果将这个 Dockerfile 进行构建镜像运行后,会发现找不到 /app/world.txt 文件,或者其内容不是 hello。原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在Dockerfile中,这两行RUN**命令的执行环境根本不同,是两个完全不同的容器。**这就是对 Dockerfile 构建分层存储的概念不了解所导致的错误。
之前说过每一个RUN**都是启动一个容器、执行命令、然后提交存储层文件变更。**第一层 RUN cd /app 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。
因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令。
| |
如果你的 WORKDIR 指令使用的相对路径,那么所切换的路径与之前的 WORKDIR 有关:
| |
RUN pwd 的工作目录为 /a/b/c。
USER 指定当前用户
如果以 root 执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用 su 或者 sudo,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。建议使用 gosu。
HEALTHCHECK 健康检查
HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令 支持下列选项:--interval=<间隔>:两次健康检查的间隔,默认为 30 秒;--timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;--retries=<次数>:当连续失败指定次数后,则将容器状态视为unhealthy,默认 3 次。 和CMD,ENTRYPOINT一样,HEALTHCHECK只可以出现一次,如果写了多个,只有最后一个生效。
Web Demo
| |
shell
| |
ONBUILD 为他人做嫁衣
LABEL 为镜像添加云数据
SHELL 指令
参考文档
实战:构建自己的Centos
Docker Hub中99%的镜像都是从这个基础镜像过来的 FROM scratch,然后配置需要的软件和配置来进行构建
| |
额外技巧:查看其它官方镜像的步骤
| |
CMD和ENTRYPOINT对比
| |
CMD测试
| |
ENTRYPOINT测试
| |
实战:DockerFile制作Tomcat镜像
1、准备镜像文件tomcat压缩包、JDK压缩包
2、编写dockerfile文件,官方明明Dockerfile,build会自动寻找这个文件,就不需要-f指定了
Expand/Collapse Code Block
| |
3、构建镜像
| |
4、运行
| |
发布镜像到DockerHub
1、地址 https://hub.docker.com 注册账号
2、提交镜像
| |
3、提交镜像
| |
Dockerfile demo
nginx
| |
Docker网络
理解Docker0

| |
原理: 每启动一个docker容器,docker就会给容器分配一个ip,只要安装了docker,就会有一个网卡docker0桥接模式,使用的技术是evth-pair技术
每启动一个容器带来的网卡都是一对一对的
evth-pair 就是一对的虚拟设备接口,成对出现,一段连着协议,一段彼此相连
一般利用这个特性 evth-pair 充当一个桥梁,连接各种网络设备
测试容器之间是否能够ping通
| |
结论:容器之间可以 ping 通 网络模型图:

结论:tomcat01和tomcat02公用的一个路由器,docker0
所有的容器不指定网络的情况下,都是docker0路由的,docker会给我们的容器分配一个默认的可用IP
总结:Docker使用的是Linux的桥接,宿主机中的是一个Docker容器的网桥 docker0。

Docker中的所有网络接口都是虚拟的,虚拟的转发效率高!(内网传递文件)。
容器删除,对应一对网桥没了
思考一个场景,项目不重启,ip更换,通过服务名连接访问
容器互联--link
| |
| |
原理探究:--link就是在hosts配置中了tomcat02的ip 实际开发已经不建议使用 --link 了。
docker0问题:不支持容器名直接访问!
自定义网络
网络模式
bridge:桥接docker(默认)
none:不配置网络
host:和宿主机共享网络
container:容器网络连通(用的少,局限很大)
| |
我们自定义的网络已经帮我们维护好了对应的关系,推荐使用!
网络连通

| |
跨网络操作别人,就需要使用docker network connect连接
实战:Redis集群部署
Expand/Collapse Code Block
| |
SpringBoot微服务打包Docker镜像
1、构建Spring-boot项目
2、打包应用
3、编写dockerfile
4、构建镜像
5、发布运行
Expand/Collapse Code Block
| |
Reference
16.2 - 20.docker目录
各种ID关系
layerID -> diffID -> chainID -> cacheID
layerID和diffID的对应关系在diffid-by-digest和v2metadata-by-diffid
chainID主要存在于/var/lib/docker/image/overlay2/layerdb/sha256/
cacheID主要存在于/var/lib/docker/overlay2/
layerID
layerID是压缩数据的sha256的值(Distribution根据layer compressed data计算)
压缩的layer层的哈希值为layerID,即distribution hashes,如pull镜像时显示的Layer ID。注意pull下来的是压缩的数据。
diffID
diffID是解压缩数据的sha256的值(本地由Docker根据layer uncompressed data计算)
如使用 docker [image] inspect nginx:latest,其中有一个RootFS的键值对,这里的rootfs layers的值就是diffID。
layerID与diffID的映射关系
目录:docker/image/overlay2/distribution/下
diffid-by-digest文件:digest(layerID) -> diffID
即distribution hashes和Content hashes的映射关系。也是正向查询。
v2metadata-by-diffid文件:diffid -> (digest, repository)
可以查找layer的digest及其所属的repository。也即是反向查询,可以从diffID->layerID(其实就是digest)
chainID
layer.ChainID只用于本地,根据layer.diffID计算,并用于layerdb的目录名称
chainID唯一标识了一组diffID的hash值,包含了这一层和父层(底层)
chainID(layer0) = diffID(layer0)
chainID(layerN) = SHA256hex(chainID(layerN-1) + " " + diffID(layerN))
docker/image/overlay2/layerdb/sha256 目录下保存了所有的chainID,可以发现在对应镜像的inspect中,只有第一层diffID与第一层的chainID相同。另外该文件夹包含了diff和cache-id等信息
chainID计算
| |
cacheID
cacheID的目录在 /var/lib/docker/overlay2/ 下。
镜像目录文件
Docker镜像是一个只读的容器模板,含有启动Docker容器所需的文件系统。Docker镜像的文件内容和一些运行容器的配置文件组成了Docker容器的文件系统运行环境——rootfs。
当使用docker pull下载一个nginx镜像后,可以在Docker的工作目录**/docker/image/overlay2** 下找到它的相关信息
docker/image/overlay2
distribution
diffid-by-digest/sha256
保存了digest(layerID)->diffID的映射关系
v2metadata-by-diffid/sha256
保存了**diffid -> (digest,repository)**的映射关系
**digest(layerID)**就是 pull 镜像时的 hash ID,拉取是 镜像层文件是压缩文件,压缩态
diffid 是 docker inspect 查看到的 镜像层 hash ID,此时 镜像层文件是解压缩的,解压缩态
imagedb
保存镜像的元数据信息
content/sha256
镜像的元数据信息,包括镜像架构、操作系统、默认配置、创建时间、历史信息和rootfs等。
image元数据中layer的diffID是以底层到高层的顺序记录的
metadata/sha256
layerdb
保存镜像层的关联关系
sha256
其下的目录名称是以layer的 chainID 来命名的,计算方式为:
1、如果layer是最底层,没有任何父layer,那么 diffID = chainID
2、否则,chainID(n)=sha256sum(chainID(n-1)) diffID(n))
各个chainID目录下:
1、cache-id
2、diff
3、parent
4、size
5、tar-split.json.gz
repositories.json
registry 是镜像仓库,如官方的Docker Hub。而 repository 代表镜像组(比如不同版本的 nginx 镜像集合)
该文件描述了宿主机上所有镜像的repository元数据,主要包括镜像名、tag和镜像ID。
镜像ID是Docker采用SHA256算法,根据镜像元数据配置文件计算得出的。
容器
通过上面的分析得出,镜像是由多个layer组成的文件,并在容器启动时成为容器文件系统的运行环境——只读的rootfs。而容器其实就是Dokcer利用存储驱动在只读rootfs上挂载一个可读写层后的结果。
联合挂载
联合挂载技术可以在一个挂载点同时挂载多个文件系统,将挂载点的原目录与被挂载内容进行整合,使得最终可见的文件系统将会包含整合之后的各层的文件和目录。
Linux提供了一种叫做联合文件系统的文件系统,它具备如下特性:
1、联合挂载:将多个目录按层次组合,一并挂载到一个联合挂载点。
2、写时复制:对联合挂载点的修改不会影响到底层的多个目录,而是使用其他目录记录修改的操作。
目前有多种文件系统可以被当作联合文件系统,实现如上的功能:overlay2,aufs,devicemapper,btrfs,zfs,vfs等等。而overlay2就是其中的佼佼者,也是docker目前推荐的文件系统:https://docs.docker.com/storage/storagedriver/select-storage-driver/。
OverlayFS
OverlayFS是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如 ext4fs 和 xfs 等),并不直接参与磁盘空间结构的划分,仅仅将原来系统文件中的文件或者目录进行“合并一起”,最后向用户展示“合并”的文件时在同一级的目录,这就是联合挂载技术,相对于AUFS(<1.12早期使用的存储技术),OverlayFS速度更快,实现更简单。
Linux内核为Docker提供的OverlayFS驱动有两种:Overlay和Overlay2。而Overlay2是相对于Overlay的一种改进,在lnode利用率方面比Overlay更有效。但是Overlay有环境需求:Docker版本17.06.02+,宿主机文件系统需要时EXT4或XFS格式。
OverlayFS实现方式
overlay2架构

它主要通过四类目录完成工作:
lower
底层文件系统。对于Docker来说,就是只读的镜像层;
upper
上层文件系统。对于Docker来说,就是可读写的容器层;
merged
是lowerdir和upperdir合并后的统一视图联合挂载点。对于Docker来说,就是用户视角下的文件系统;
work
工作基础目录,挂载后内容会被清空。用来存放挂载后的临时文件与间接文件。
容器实战
启动容器后,观察系统中overlay2类型的挂载情况
| |
根据输出结果可以找到overlay2的挂载点位置。总结如下: 1、/var/lib/docker/overlay2/l 目录保存的是软链接文件,其文件名是避免使用mount命令时输出结果达到页面大小限制而生成的短名称。
2、容器会自动生成一层的以读写层cache-id-init命名的init-layer,其中的文件配置了容器的主机名,DNS服务等。(init后缀的文件夹)
3、overlay2将upper和lower的文件系统联合挂载,就得到了用户视角下的文件系统
容器的文件系统分为三层: - r/o层:也就是镜像层 - init层:启动容器时的参数 - r/w层:可读写层
| |
这三个文件记录了在 /docker/overlay2/下的目录名 参考:浅谈docker中镜像和容器在本地的存储
Expand/Collapse Code Block
| |
Docker数据导出实战
脚本
export-workspace.sh
Expand/Collapse Code Block
| |
使用示例
| |
Reference
https://www.keepnight.com/archives/336/
https://juejin.cn/post/6844903574137208839
16.3 - 21.docker路径修改
相关命令
查看docker安装路径
| |
修改docker路径
Expand/Collapse Code Block
| |
docker目录
| |
容器的目录
| |
清理相关命令
Expand/Collapse Code Block
| |
修改安装路径脚本
第一版,问题:文件不存在未考虑,空文件和空内容未考虑
| |
第二版
| |
问题排查
docker启动报错
Job for docker.service failed because the control process exited with error code. See "systemctl status docker.service" and "journalctl -xe" for details.
一般是daemon.json文件配置错误,不符合json格式
| |
网络访问不通
g"docker桥接网络访问不通"
eth0 网段正好跟docker0是相同网段
在/etc/docker/daemon.json中增加一行,如:
| |
https://cloud.tencent.com/developer/article/1768097
Reference
How to move the default /var/lib/docker to another directory for Docker on Linux?
16.4 - 22.dockerd
概述
docker是client+server架构,可通过 docker version分别看到客户端和服务端的信息。
执行docker相关命令实际上是通过客户端将请求发送到当前电脑的Docker Daemon服务上,由Docker Daemon返回信息,客户端收到信息后展示在控制台上。
官方解释:https://docs.docker.com/engine/reference/commandline/dockerd/#description
Docker组件
Docker CLI(docker)
docker 程序是一个客户端工具,用来把用户的请求发送给 docker daemon(dockerd)。
该程序的安装路径为:
| |
Dockerd
docker daemon(dockerd),一般也会被称为 docker engine。
该程序的安装路径为:
| |
Containerd
在宿主机中管理完整的容器生命周期:容器镜像的传输和存储、容器的执行和管理、存储和网络等。
该程序的安装路径为:
| |
Containerd-shim
它是 containerd 的组件,是容器的运行时载体,主要是用于剥离 containerd 守护进程与容器进程,引入shim,允许runc 在创建和运行容器之后退出,并将 shim 作为容器的父进程,而不是 containerd 作为父进程,这样做的目的是当 containerd 进程挂掉,由于 shim 还正常运行,因此可以保证容器不受影响。此外,shim 也可以收集和报告容器的退出状态,不需要 containerd 来 wait 容器进程。我们在 docker 宿主机上看到的 shim 也正是代表着一个个通过调用 containerd 启动的 docker 容器。
该程序的安装路径为:
| |
RunC
RunC 是一个轻量级的工具,它是用来运行容器的,容器作为 runC 的子进程开启,在不需要运行一个 Docker daemon 的情况下可以嵌入到其他各种系统,也就是说可以不用通过 docker 引擎,直接运行容器。docker是通过Containerd调用 runC 运行容器的
该程序的安装路径为:
| |
Docker Deamon连接方式
官方文档:https://docs.docker.com/engine/reference/commandline/dockerd/#description
Unix套接字
默认就是这种方式, 会生成一个 /var/run/docker.sock 文件, UNIX 域套接字用于本地进程之间的通讯, 这种方式相比于网络套接字效率更高, 但局限性就是只能被本地的客户端访问。
TCP端口监听
服务端开启端口监听 dockerd -H IP:PORT , 客户端通过指定IP和端口访问服务端 docker -H IP:PORT 。通过这种方式, 任何人只要知道了你暴露的ip和端口就能随意访问你的docker服务了, 这是一件很危险的事, 因为docker的权限很高, 不法分子可以从这突破取得服务端宿主机的最高权限。
连接方式设置
| |
问题
dockerd -H fd://是什么意思
-H fd://在 systemd 中运行 docker 时使用该语法。Systemd 本身会在 docker.socket 单元文件中创建一个套接字并监听它,这个套接字使用fd://docker.service 单元文件中的语法连接到 docker 守护进程。
所以直接使用以下命令是错误的,因为不是使用systemd
| |
参考: https://stackoverflow.com/questions/43303507/what-does-fd-mean-exactly-in-dockerd-h-fd
https://blog.csdn.net/michaelwoshi/article/details/107601744
API
详见:https://docs.docker.com/engine/api/v1.39/
实战
容器内访问dockerd
挂载以下两个目录即可:
| |
如果指定docker命令报错:
| |
执行以下命令解决:
| |
模拟http请求
| |
Reference
https://docs.docker.com/engine/api/v1.39/
https://docs.docker.com/engine/reference/commandline/dockerd/#description
16.5 - 51.kubernetes01
介绍说明
中文文档:https://kubernetes.io/docs/home/
资源管理器对比
K8S优势
- 轻量级:消耗资源少
- 开源
- 弹性伸缩
- 负载均衡:IPVS 适合人群:软件工程师、测试工程师、运维工程师、软件工程师、运维
K8S组件说明
K8S架构

Api-server:所有服务访问统一入口
Controller Manager:维持副本期望数目
Scheuler:负责介绍任务,选择合适的节点进行分配任务
ETCD:键值对数据库,储存K8S集群所有重要信息(持久化)
Kubelet:直接跟容器引擎交互实现容器的声明周期管理
Kube-proxy:负责写入规则至 IPTABLES、IPVS,实现服务映射访问
CoreDNS:为集群中的SVC创建一个域名IP的对应关系解析
Dashboard:给K8S集群提供一个 B/S 结构访问体系
Ingress:官方只能实现四层代理,INGRESS可以实现七层代理
Federation:提供一个可以跨集群中心多K8S统一管理功能
Prometheus:提供K8S集群的监控能力
ELK:提供K8S集群日志统一分析介入平台
额外说明
kubeadm:用来初始化集群的指令。
kubelet:在集群中的每个节点上用来启动 pod 和容器等。
kubectl:用来与集群通信的命令行工具
Borg组件说明
K8S结构说明
网络结构
组件结构
基础概念
Pod概念
自主式Pod
Pod退出了,此类型的Pod不会被创建
控制器管理的Pod
在控制器的生命周期里,始终要维持Pod的副本数目
Pod控制器类型
RS、RC概念
ReplicationController
ReplicaSet
deployment概念
HPA概念
Horizontal Pod Autoscaling 仅适用于Deployment和ReplicaSet,在V1版本中仅支持根据Pod的CPU利用率扩缩容,在v1alpha版本中,支持根据内存和用户自定义的metric扩缩容
StatefullSet概念
StatefulSet是为了解决有状态服务的问题(对应Deployments和ReplicaSets是为无状态服务而设计),其应用场景包括:
1、稳定的持久化存储, 即Pod重新调度后还是能访问到相同的持久化数据, 基于PVC来实现
2、稳定的网络标志, 即Pod重新调度后其PodName和HostName不变, 基于Headless Service
(即没有ClusterIP的Service)来实现
3、有序部署,有序扩展,即Pod是有顺序的,在部署或者扩展的时候要依据定义的顺序依次依次进行(即从0到N-1,在下一个Pod运行之前所有之前的Pod必须都是 Running 和 Ready 状态), 基于 init containers 来实现
4、有序收缩, 有序删除(即从N-1到0)
DaemonSet概念
DaemonSet 确保全部(或者一些)Node上运行一个Pod的副本。当有Node加入集群时,也会为他们新增一个Pod。当有Node从集群移除时, 这些Pod也会被回收。删除DaemonSet将会删除它创建的所有Pod。
使用DaemonSet的一些典型用法:
1、运行集群存储 daemon,例如在每个Node上运行 glusterd、ceph。
2、在每个 Node 上运行日志收集 daemon,例如 fluentd、1ogstash。
3、在每个 Node 上运行监控 daemon,例如 Prometheus Node Exporter
Job、CronJob概念
Job负责批处理任务,即仅执行一次的任务,它保证批处理任务的一个或多个Pod成功结束
Cron Job管理基于时间的Job,即:
1、在给定时间点只运行一次
2、周期性地在给定时间点运行
服务发现
Pod协同
网络通讯模式
Kubernetes的网络模型假定了所有Pod都在一个可以直接连通的扁平的网络空间中,这在
GCE(Google Compute Engine)里面是现成的网络模型,Kubernetes假定这个网络已经存在。
而在私有云里搭建Kubernetes集群,就不能假定这个网络已经存在了。我们需要自己实现这
个网络假设,将不同节点上的Docker容器之间的互相访问先打通,然后运行Kubernetes
同一个 Pod 内的多个容器:localhost(lo)
各 Pod 之间的通讯:Overlay Network
Pod 与 Service之间的通讯:各节点的 Iptables 规则
Flannel 是 Core0S 团队针对 Kubernetes 设计的一个网络规划服务,简单来说,它的功能是
让集群中的不同节点主机创建的Docker容器都具有全集群唯一的虚拟IP地址。而且它还能在
这些IP地址之间建立一个覆盖网络(0verlayNetwork),通过这个覆盖网络,将数据包原封
不动地传递到目标容器内。
基本思想是二次封装做转发。如下图所示:

ETCD 之 Flannel 提供说明:
1、存储管理 Flannel 可分配的 IP 地址段资源
2、监控 ETCD 中每个 Pod 的实际地址,并在内存中建立维护 Pod 节点路由表
同一个Pod内部通讯
同一个Pod共享同一个网络命名空间, 共享同一个Linux协议栈
Podl至Pod2
1、Podl与Pod2不在同一台主机
Pod的地址是与docker0在同一个网段的, 但docker0网段与宿主机网卡是两个完全不同的IP网段, 并且不同Node之间的通信只能通过宿主机的物理网卡进行。将Pod的IP和所在Node的IP关联起来, 通过这个关联让Pod可以互相访问(如经过Flannel)
2、Pod与Pod2在同一台机器
由Docker0网桥直接转发请求至Pod2, 不需要经过Flannel
Pod至Service的网络
目前基于性能考虑, 全部为iptables维护和转发(最新使用LVS性能会更高)
Pod到外网
Pod向外网发送请求, 查找路由表, 转发数据包到宿主机的网卡, 宿主网卡完成路由选择后, iptables执行Masaquerade, 把源IP更改为宿主网卡的IP, 然后向外网服务器发送请求
外网访问Pod
Service
网络通讯模式说明
组件通讯模式说明
K8S安装
安装方式介绍
minikube
只是一个K8S集群模拟器,只有一个节点的集群,只为测试用,master和worker都在一起
需要提前安装好docker。
| |
云平台搭建
可视化搭建,只需简单几步就可以创建好一个集群
优点:安装简单,生态齐全,负载均衡器、存储等都分配好。简单操作就行。
1、腾讯云TKE-控制台搜索容器
2、阿里云控制台-产品搜索Kubernetes
裸机安装(Bare Metal)
至少需要两台机器(主节点、工作节点各一台),需要自己安装Kubernetes组件,配置会稍微麻烦点。可以租用云厂商服务器,费用低,用完销毁。
缺点:配置麻烦,缺少生态支持,例如负载均衡器、云存储。
主节点需要组件
1、docker(也可以其它容器运行时)
2、kubectl集群命令行交互工具
3、kubeadm集群初始化工具
工作节点需要组件
1、docker(也可以其它容器运行时)
2、kubelet管理Pod和容器,确保健康运行
3、kube-proxy网络代理,负责网络相关工作
开始安装
| |
| |
| |
注意:所有节点确保防火墙关闭
| |
添加安装源(所有节点)
| |
安装所需组件(所有节点)
| |
启动 kubelet、docker,并设置开机启动(所有节点)
| |
修改 docker 配置(所有节点)
| |
用 kubeadm 初始化集群(仅在主节点跑),
| |
有兴趣了解 kubeadm init 具体做了什么的,可以 查看文档 把工作节点加入集群(只在工作节点跑)
| |
安装网络插件,否则 node 是 NotReady 状态(主节点跑)
| |
查看节点,要在主节点查看(其他节点有安装 kubectl 也可以查看)
系统初始化
K8S部署安装
Harbor使用Cenot7或内核3.1以上(最新最好4.4,有些bug导致docker意外重启)能避免较多问题(老版本不支持某些命名空间、Overlay的文件系统需要编译内核加载等)

Node安装
kubeadm安装k8s,kube-system命名空间
Harbor安装
私有镜像仓库
常见问题分析
资源清单
K8S资源概念
资源定义
K8S中所有的内容都抽象为资源,资源实例化之后,叫做对象。
名称空间级别资源
工作负载型资源(workload)
Pod、ReplicaSet、Deployment、StatefulSet、DaemonSet、Job、CronJob(ReplicationController在v1.11版本被废弃)
服务发现及负载均衡型资源(ServiceDiscovery LoadBalbance)
Service、Ingress、...
配置与存储型资源
Volume(存储卷)、GSI(容器存储接口,可以扩展各种各样的第三方存储卷)
特殊类型的存储卷
ConfigMap(当配置中心来使用的资源类型)、Secret(保存敏感数据)、DownwardAPI(把外部环境中的信息输出给容器)
集群级别资源
Namespace、Node、Role、ClusterRole、RoleBinding、ClusterRoleBinding
元数据型资源
HPA、PodTemplate、LimitRange
资源清单含义
在K8S中,一般使用yaml格式的文件来创建符合我们预期期望的pod,这样的yaml文件我们一般称为资源清单
yaml语法格式
基本语法
1、缩进时不允许使用Tab键,只允许使用空格
2、缩进的空格数目不重要,只要相同层级的元素左侧对齐即可
3、标识注释,从这个字符一直到行尾,都会被解释器忽略
yaml支持的数据结构
对象
键值对的集合,又称为映射(mapping)/哈希(hashes)、字典(dictionary)
数组
一组按次序排列的值,又称为序列(sequence)/列表
纯量(scalars)
单个的、不可再分的值。
对象类型
对象的一组键值对,使用冒号结构表示
| |
yaml也允许另外一种写法,将所有键值对写成一个行内对象
| |
数组也可以采用行内表示法
| |
纯量 如字符串、布尔值、整数、浮点数、null、时间、日期
其中null用~表示
允许使用两个感叹号,强制转换数据类型,如
| |
字符串 默认不需要使用引号表示,如果包含空格或者特殊字符,需要放在引号之中。其中双引号不会对特殊字符转义。单引号之中如果还有需要转义的单引号,必须连续使用两个单引号转义。
多行字符串可以写成多行,从第二行开始,必须有一个单空格缩进。换行符会被转为空格。
多行字符可以使用 | 保留换行符,也可以使用 > 折叠换行
| |
+表示保留文字块末尾的换行,-表示删除字符串末尾的换行
| |
常用字段说明
可以通过下面命令查询字段含义
| |
| 参数名 | 字段类型 | 说明 |
|---|---|---|
| version | String | K8S API的版本,目前基本上是v1,可以用kubectl api-versions命令查询 |
| kind | String | yaml文件定义的资源类型和角色,如:Pod |
| metadata | Object | 元数据对象,固定值就写metadata |
| metadata.name | String | 元数据对象的名字,由自己编写,如命名Pod名字 |
| metadata.namespace | String | 元数据对象的命名空间,由自己编写 |
| Spec | Object | 详细定义对象,固定值就写Spec |
| spec.containers[] | list | Spec对象的容器列表定义,列表 |
| spec.containers[].name | String | 容器的名字 |
| spec.containers[].image | String | 镜像名称 |
| spec.containers[].imagePullPolicy | String | 镜像拉取策略,有Always(默认值)、Never(仅适用本地镜像)、IfNotPreset三个值 |
| spec.containers[].command[] | List | 容器启动命令,不指定则使用镜像打包时使用的启动命令 |
| spec.containers[].args[] | List | 指定容器启动命令参数 |
| spec.containers[].workingDir | String | 指定容器的工作目录 |
| spec.containers[].volumeMounts[] | List | 容器的存储卷配置 |
| spec.containers[].volumeMounts[].name | String | 被容器挂载的存储卷名称 |
| spec.containers[].volumeMounts[].mountPath | String | 被容器挂载的存储卷路径 |
| spec.containers[].volumeMounts[].readOnly | String | 设置存储卷路径的读写模式,true/false,默认true |
| spec.containers[].ports[] | List | 指定容器需要用到的端口列表 |
| spec.containers[].ports[].name | String | 端口名称 |
| spec.containers[].ports[].containerPort | String | 容器需要监听的端口号 |
| spec.containers[].ports[].hostPort | String | 指定容器所在主机需要监听的端口号,默认跟containerPort相同,注意设置了该值同一台主机无法启动容器的相同副本(端口号冲突) |
| spec.containers[].ports[].protocol | String | 指定端口协议,支持TCP(默认)和UDP |
| spec.containers[].env[] | List | 容器运行前需要设置的环境变量列表 |
| spec.containers[].env[].name | String | 环境变量名称 |
| spec.containers[].env[].value | String | 环境变量值 |
| spec.containers[].resources | Object | 资源限制和资源请求的值(资源上限) |
| spec.containers[].resources.limits | Object | 容器运行时资源的运行上限 |
| spec.containers[].resources.limits.cpu | String | CPU的限制,单位为core数,将用于docker run --cpu-shares参数 |
| spec.containers[].resources.limits.memory | String | MEM内存的限制,单位为MiB、GiB |
| spec.containers[].resources.requests | Object | 容器启动和调度室的限制设置 |
| spec.containers[].resources.requests.cpu | String | CPU请求,单位为core数,容器启动时初始化可用数量 |
| spec.containers[].resources.requests.memory | String | 内存请求,单位为MiB、GiB,容器启动的初始化可用数量 |
| spec.restartPolicy | String | 定义Pod的重启策略,可选值为Always(默认值)、OnFailure(只有Pod以非零退出码终止时,kubelet才会重启该容器)、Never(kubelet将退出码报告给Master,不重启) |
| spec.nodeSelector | Object | Node的Label过滤标签,以key:value格式指定 |
| spec.imagePullSecrets | Object | pull镜像时使用secret名称,以name:secretkey格式指定 |
| spec.hostNetwork | Boolean | 是否使用主机网络模式,默认false,true表示使用宿主机网络,不适用docker网桥,同时设置了true无法在同一台宿主机上启动第二个副本 |
查看pod容器日志
| |
通过资源清单编写pod
pod的生命周期

initC
init Contianer:init 容器
Pod能够具有多个容器,应用运行在容器里面,但是也有可能有一个或多个先于应用容器启动的Init容器
Init容器与普通的容器非常像,除了以下两点:
1、Init容器总是运行到成功完成为止
2、每个Init容器都必须在下一个Init容器启动之前成功完成
如果Pod的Init容器失败,Kubernetes会不断重启该Pod,知道Init容器成功为止。如果Pod对应的restartPolicy为Never,则不会重启。
init容器的作用
因为Init容器具有与应用程序容器分离的单独镜像,所以它们的启动相关代码有如下优势:
1、它们可以包含并运行实用工具,但出于安全考虑,不建议在应用程序容器镜像中包含这些使用工具
2、可以包含使用工具和定制化代码来安装,但不能出现在应用程序镜像中。如:创建镜像没必要From另外一个镜像,只需要在安装过程中使用类似sed、awk、python或dig这样的工具
3、应用程序镜像可以分离出创建和部署的角色,而没有必要联合它们构建一个单独的镜像
4、Init容器使用Linux Namespace,所以相对应用程序容器来说具有不同的文件系统视图。因此,它们能够具有访问Secret的权限,而应用程序容器则不能。
5、它们必须在应用程序容器启动之前运行完成,而应用程序容器式并行运行的,所以Init容器能够提供一种简单的阻塞或延迟应用容器的启动的方法,知道满足了一组先决条件。
Init模板
init-pod.yaml
| |
运行命令:
| |
myservice
| |
mydb.yaml
kind: Service
apiVersion: v1
metadata:
name: mydb
spec:
ports:
- protocol: TCP
port: 80
targetPort: 9377
运行命令:
| |
特殊说明 1、在Pod启动过程中,Init容器会按顺序在网络和数据卷初始化(Pause容器)之后启动。每个容器必须在下一个容器启动之前成功退出
2、如果由于运行时或失败退出,将导致容器启动失败,他会根据Pod的restartPolicy指定的策略进行重试。然而,如果Pod的restartPolicy设置为Always,Init容器失败时会使用RestartPolicy策略。
3、在所有的Init容器没有成功之前,Pod将不会编程Ready状态。Init容器的端口将不会在Service中进行聚集(不会暴露出去)。正在初始化的Pod处于Pending状态,但应该会将Initializing状态设置为true
4、如果Pod重启,所有init容器必须重新执行
5、对Init容器spec的修改被限制在容器image字段,修改其他字段都不会生效。更改init容器的image字段,等价于重启该Pod
| |
6、Init容器具有应用容器的所有字段。除了readinessProbe,因为Init容器无法定义不同于完成(completion)的就绪(readiness)之外的其他状态。这会在验证过程中强制执行。(init容器本身就是做就绪的工作,再来就绪检测就呵呵) 7、在Pod中的每个app和Init容器的名称必须唯一;与任何其它容器共享同一个名称,会在验证时抛出错误
Pod phase
挂起(Pending)
Pod已被Kubernetes系统接受, 但有一个或多个容器镜像尚未创建。等待时间包括调度Pod的时间和通过网络下载镜像的时间, 这可能需要花点时间
运行中(Running)
该Pod已经绑定到了一个节点上, Pod中所有的容器都已被创建。至少有一个容器正在运行, 或者正处于启动或重启状态
成功(Succeeded)
Pod中的所有容器都被成功终止, 并且不会再重启
失败(Failed)
Pod中的所有容器都已终止了, 并且至少有一个容器是因为失败终止。也就是说, 容器以非0状态退出或者被系统终止
未知(Unknown)
因为某些原因无法取得Pod的状态,通常是因为与Pod所在主机通信失败
容器探针
探针是由kubelet对容器执行的定期诊断。要执行诊断,kubelet调用由容器实现的Handler。有三种类型的处理程序:
1、ExechAction:在容器内执行指定和令。如果佐令退出时返回码为0则认为诊断成功。
2、TCPSockethction: 对指定端口上的容器的IP地址进行TCP检查。如果踹口打开, 则识断
被认为是成功的。
3、HTTPGethction:对指定的端口和路径上的容器的IP地址执行HTTP Get请求。如果响应的
状态码大于等于200且小于400,则诊断被认为是成功的
每次探测都将获得以下三种结果之一:
1、成功: 容器通过了诊断。
2、夫败: 容器未通过诊断。
3、未知: 诊断失败,因此不会采取任何行动
livenessProbe
指示容器是否正在运行。如果存活探测失败,则kubelet会杀死容器,并且容器将|受到其重启策略的影响。如果容器不提供存活探针, 则默认状态为Success
redinessProbe-httpget
| |
进入容器
| |
readinessProbe
指示容器是告准备好服务请求。如果就绪探测失败, 端点控制器将从与Pod匹配的所有Service的踹点中删除该Pod的IP地址。初始延迟之前的就绪状态默认为Failure。如果容器不提供就绪探针, 则默认状态为Success
livenessProbe-exec
| |
创建并观察
| |
livenessProbe-httpgetExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: Pod
metadata:
name: liveness-httpget-pod
namespace: default
spec:
containers:
- name: liveness-httpget-container
image: xxx:v1
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
livenessProbe:
httpGet:
port: http
path: /index.html
initialDelaySeconds: 1
periodSeconds: 3
timeoutSeconds: 10
创建并观察
| |
livenessProbe-tcp
| |
Pod hook
启动退出动作
| |
重启策略
Pod控制器
什么是控制器
Kubernets中内建了很多controller(控制器),相当于一个状态机,用来控制Pod的具体状态和行为
控制器类型说明
ReplicationController和ReplicaSet
ReplicationController(RC) 用来确保容器应用的副本数始终保持在用户定义的副本数,即如果有容器异常退出,会自动创建新的Pod来替代;而如果异常多出来的容器也会自动回收
在新版本的Kubernetes中建议使用ReplicaSet来取代ReplicationController。ReplicaSet跟
ReplicationController没有本质的不同, 只是名字不一样, 并且ReplicaSet支持集合式的selector(通过标签匹配)
RS实战
| |
RS demoExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: extensions/v1beta1
kind: ReplicaSet
metadata:
name: frontend
spec:
replicas: 3
selector:
machLabels:
tier: frontend
template:
metadata:
labels:
tier: frontend
spec:
containers:
- name: php-redis
image: gcr.io/google_samples/gb-fronted:v3
env:
- name: GET_HOSTS_FROM
value: dns
ports:
- containerPort: 80
操作
| |
注意:RS根据标签名管理,如果修改了标签名则删除相关rs不会影响修改了label的pod
Deployment
Deployment为Pod和ReplicaSet提供了一个声明式定义(declarative)方法,用来替代以前的
ReplicationController来方便的管理应用。典型的应用场景包括:
1、定义Deployment来创建Pod和ReplicaSet
2、滚动升级和回滚应用
3、扩容和缩容(RS)
4、暂停和继续Deployment

Deployment更新策略
Deployment可以保证在升级时只有一定数量的Pod是down的。默认确保至少有比期望的Pod数量少一个是up状态(最多一个不可用)
Deployment同时也可以确保只创建出超过期望数量的一定数量的Pod。默认确保最多比期望的Pod数量多一个的Pod是up状态(最多1个surge)
未来的K8S版本中,将从1-1变成25%-25%
| |
Rollover(多个rollout并行) 假如创建了一个有5个nginx:1.7.9 replica的Deployment,但是当还只有3个ngnix:1.7.9的replica创建出来的时候就开始更新含有5个nginx:1.9.1 replica的Deployment。这种情况下,Deployment会立即杀掉已创建的3个nginx:1.7.9的Pod,并开始创建nginx:1.9.1的Pod。不会等到所有的5个nginx:1.7.9的Pod都创建完成后才开始改变航道
回退Deployment
只要Deployment的rollout被触发就会创建一个revision。也就是说当且仅当Deployment的Pod template(如.spec.template)被更改,例如更新template中的label和容器镜像时,就会创建出一个新的revision。其它的更新如扩容Deployment不会创建revision,因此可以很方便手动或自动扩容。这意味着回退到历史revision时,只有Deployment中的Pod template部分才会回退。
| |
清理Policy 可以通过设置.spec.revisionHistoryLimit项来指定deployment最多保留多少revision历史记录。默认会保留所有的revision,如果将该项设置为0,Deployment就不允许回退。
Deployment实战部署Nginx
配置文件
| |
创建
| |
DaemonSet
Daemonset 确保全部(或者一些) Node上运行一个Pod的副本。当有Node加入集群时, 也会为他们新增一个Pod。当有Node从集群移除时, 这些pod也会被回收。删除DaemonSet将会删除它创建的所有Pod。
使用DaemonSet的一些典型用法:
1、运行集群存储daemon, 例如在每个Node上运行glusterd、ceph
2、在每个Node上运行日志收集daemon, 例如fluentd、logstash
3、在每个Node上运行监控daemon, 例如Prometheus Node Exporter、collectd、Datadog代理、New Relic代理, 或Ganglia gmond
DaemonSet实战
| |
Job
Job负责批处理任务,即仅执行一次的任务,保证批处理任务的一个或多个Pod成功结束
特殊说明:
1、spec.template格式同Pod
2、RestartPolicy仅支持Never或OnFailure
3、单个Pod时,默认Pod成功运行后Job即结束
4、.spec.completions标志Job结束需要成功运行的Pod个数,默认为1
5、.spec.parallelism标志并行运行的Pod的个数,默认为1
6、spec.activeDeadlineSeconds标志失败Pod的重试最大时间,超过这个时间不会继续重试
Job实战
| |
查看日志,可以显示打印的2000位的PI值
| |
CronJob
CronJob管理基于时间的Job,即:
1、在给定的时间点只运行一次
2、周期性地在给定时间点运行
使用前提条件:当前使用k8s集群,版本>=1.8(对CronJob)。对于先前版本的集群,版本<1.8,启动API Server时,通过传递选项
| |
可以开启batch/v2alpha1 API 典型用法:
1、给定的时间点调度Job运行
2、创建周期性运行的Job,例如:数据库备份、发送邮件
CronJob Spec
1、.spec.schedule:调度,必需字段,指定任务运行周期,格式Cron
2、.spec.jobTemplate:Job模板,必需字段,指定需要运行的任务,格式同Job
3、.spec.startingDeadlineSeconds:启动Job的期限(秒级别),可选字段,如果因为任何原因而错过了被调度的时间,那么错过执行时间的Job将被认为是失败的。没有指定则没有期限。
4、.spec.concurrencyPolicy:并发策略,可选字段。指定如何处理被Cron Job创建的Job的并发执行。只允许指定下面策略中的一种:
1)Allow(默认):允许并发运行的Job
2)Forbid:禁止并发运行,如果前一个还没有完成,则直接跳过下一个
3)Replace:取消当前正在运行的Job,用一个新的来替换
注意:当前策略只能应用于同一个Cron Job创建的Job。如果存在多个Cron Job,它们创建的Job之间总是允许并发运行。
5、.spec.suspend:挂起,字段可选。true表示后续所有执行都会被挂起。对已经开始执行的Job不起作用。默认值false
6、.spec.successfulJobsHistoryLimit和.spec.failedJobsHistoryLimit:历史限制,可选字段,指定可以保留多少完成和失败的Job,默认分别设置2和1,设置限制的值为0则相关类型的Job完成后将不会被保留。
CronJob的限制
创建job操作应该是幂等的。
CronJob实战
| |
创建
| |
StatefulSet
StatefulSet作为 Controller 为Pod提供唯一的标识,可以保证部署和scale的顺序
StatefulSet是为了解决有状态服务的问题(对应Deployments和ReplicaSets是为无状态服务而设计),其应用场景包括:
1、稳定的持久化存储,即Pod重新调度后还是能访问到相同的持久化数据,基于PVC实现
2、稳定的网络标志,即Pod重新调度后其PodName和HostName不变,基于Headless Service(即没有Cluster IP的Service)来实现
3、有序部署,有序扩展,即Pod是有顺序的,在部署或者扩展的时候要依据定义的顺序依次进行(即从0到N-1,在下一个Pod运行之前所有之前的Pod必须都是Running和Ready状态),基于init containers来实现
4、有序收缩,有序删除(即从N-1到0)
Horizontal Pod Autoscaling(HPA)
顾名思义,Pod水平自动缩放。应用的资源使用率通常都有高峰和低谷的时候,它可以削峰填谷,提高集群的整体资源利用率,让service中的Pod个数自动调整。
这个并不是一个直接的控制器,而是控制如Deployment
服务发现(SVC)
Service原理
所有服务都注册到SVC,上层Nginx与SVC交互,不与具体的服务ip打交道。
只有一个调度算法轮询(RR)。

Service含义
K8S Service定义了这样一个抽象:一个Pod的逻辑分组,一种可以访问它们的策略——通常称为微服务。这一组Pod能够被Serice访问到,通常是通过Label Selector
Service能够提供负载均衡的能力,但是在使用上有以下限制:
1、只提供4层负载均衡能力(只基于ip+port进行转发),而没有7层负载均衡能力(不能通过主机名/域名),但有时可能需要更多的匹配规则来转发消息,4层负载均衡是不支持的。后面介绍通过Ingress来实现7层负载均衡
Service常见分类
Service在K8S中有以下四种类型:
ClusterIP
默认类型,自动分配一个仅Cluster内部可以访问的虚拟IP。
clusterIP主要在每个node节点使用iptables(实际依赖具体环境底层代理模式,也可以是ipvs或userspace,下文有介绍),将发向clusterIP对应端口的数据,转发到kube-proxy中。然后kube-proxy自己内部实现有负载均衡的方法,并可以查询到这个service下对应pod的地址和端口,进而把数据转发给对应的pod的地址和端口。

为了实现图上的功能, 主要需要以下几个组件的协同工作:
1、apiserver:用户通过kubectl命令向apiserver发送创建service的命令,apiserver接收到请求后将数据存储到etcd中
2、Kube-proxy:kubernetes的每个节点中都有一个叫做kube-porxy的进程,这个进程负责感知service、pod的变化(检听etcd变化),并将变化的信息写入本地的iptables规则中
3、iptables使用NAT等技术将virtuallP的流量转至endpoint中
实战
svc-deployment.yaml
Expand/Collapse Code Block
| |
创建
| |
创建Service信息
svc-service.yaml
| |
创建
| |
NodePort
在ClusterIP基础上为Service在每台机器上绑定一个端口,可以通过
nodePort的原理在于在node上开了一个端口,将向该端口的流量导入到kube-proxy,然后由kube-proxy进一步地给到对应的pod
实战
配置信息除了将spec.type改成NodePort外,其它同ClusterIP的配置
注意:其实一组pod可以对应多个svc,因为svc是根据label进行关联的。如这里NodePort的配置文件和ClusterIP的配置文件除了spec.type外完全一致的情况下。
创建并验证
| |
LoadBalancer
在NodePort的基础上,借助cloud provider创建一个外部负载均衡器,并将请求转发到
localBalancer 和 nodePort 其实是同一种方式。区别在于loadBalancer比nodePort多了一步,就是可以调用cloud provider去创建LB来向节点导流(LB一般是LAAS,但是收费!)

ExternalName
把集群外部的服务引入到集群内部来,在集群内部直接使用。没有任何类型代理被创建,只有k8s 1.7或更高版本的kube-dns才支持
这种类型的Service通过返回 CNAME 和它的值, 可以将服务映射到externalName字段的内容。ExternalNameService是Service的特例,没有selector,也没有定义任何的端口和Endpoint。相反对于运行在集群外部的服务, 它通过返回该外部服务的别名这种方式来提供服务。
实战
| |
当查询主机 my-service1.default.svc.cluster.local(SVC_NAME.NAMESPECE.svc.cluster.local)时,集群的DNS服务将返回一个值my.database.example.com的CNAME记录。访问这个服务的工作方式和其他相同,唯一不同的是重定向发生在DNS层,而且不会进行代理或转发。
Headless Service
有时不需要或不想要负载均衡,以及单独的Service IP。遇到这种情况,可以通过制定Cluster IP(spec.clusterIP)的值为"None"来创建Headless Service。这类Service并不会分配Cluster IP,kube-proxy不会处理他们,而且平台不会为他们进行负载均衡和路由。
举例
svc-headless.yaml
| |
创建验证
| |
Service代理模式分类
总体结构

VIP和Service代理背景
在K8S集群中,每个Node运行一个kube-proxy进程,kube-proxy负责为Service实现了一种**VIP(虚拟IP)**的形式,而不是ExternalName的形式。
在K8S v1.0版本,代理完全在userspace。
在K8S v1.1版本,新增了iptables代理,但并不是默认的运行模式。
从K8S v1.2起,默认就是iptables代理。
在K8S v1.8.0-beta.0中,添加了ipvs代理。
在K8S v1.14八本开始默认使用ipvs代理
在K8S v1.0版本中,Service是 **4层(TCP/UDP over IP)**概念。
在K8S v1.1版本,新增Ingress API(beta版),用来表示**7层(HTTP)**服务
为什么不适用 round-robin DNS?
DNS一般会缓存。一般服务解析后一般不会清除缓存
userspace
userpace代理模式

服务的访问和apiserver都会访问kube-proxy,导致压力比较大。
iptables
iptables代理模式

访问速率大大增加,kube-proxy能提高稳定性、减小压力。
缺点:防火墙的性能不高
ipvs
kube-proxy会监视K8S Service对象和Endpoints,调用netlink接口以相应地创建ipvs规则并定期与K8S Service对象和Endpoints对象同步ipvs规则,以确保ipvs状态与期望一直。访问服务时,流量将重定向到其中一个后端Pod。
与iptables类似,ipvs于netfilter的hook功能,但使用哈希表作为底层数据结构并在内核空间中工作。这意味着ipvs可以更快地重定向流量,并且在同步代理规则时具有更好的性能。此外,ipvs为负载均衡算法提供了更多的选项。例如:
1、rr:轮询调度
2、lc:最小连接数
3、dh:目标哈希
4、sh:源哈希
5、sed:最短期望延迟
6、nq:不排队调度
注意:ipvs模式假定在运行kube-proxy之前在节点上都已经安装了 IPVS 内核模块。当kube-proxy以ipvs代理模式启动时,kube-proxy将验证节点上是否安装了IPVS模块,如果未安装,则kube-proxy将回退到iptables代理模式。
ipvs代理模式

相关命令
| |
Ingress
github ingress:https://github.com/kubernetes/ingress-nginx
官方网站:https://kubernetes.github.io/ingress-nginx/
中文文档:https://kubernetes.io/zh/docs/concepts/services-networking/ingress/
Ingress整体流程

部署Ingress-Nginx
| |
保存镜像到本地
| |
创建
| |
HTTP代理访问
deployment/Service/Ingress yaml文件
Expand/Collapse Code Block
| |
相关命令
| |
HTTPS代理访问
创建整数,以及cert存储方式
| |
Deployment/Service/Ingress yaml文件 deployment和svc同上,修改label即可
ingress-https.yaml
| |
使用cookie实现会话关联
参考官方文档:
https://kubernetes.github.io/ingress-nginx/examples/affinity/cookie/
BasicAuth
参考官方用例:https://kubernetes.github.io/ingress-nginx/examples/auth/basic/
Nginx进行基础认证,用户密码登录
| |
| |
Nginx进行重写
| 名称 | 描述 | 值 |
|---|---|---|
| nginx.ingress.kubernetes.io/rewrite-target | 必须重定向流量的目标URI | 串 |
| nginx.ingress.kubernetes.io/ssl-redirect | 指示位置部分是否仅可访问SSL(当Ingress包含整数时默认为True) | 布尔 |
| nginx.ingress.kubernetes.io/force-ssl-redirect | 即使Ingress未启用TLS,也前世重定向到HTTPS | 布尔 |
| nginx.ingress.kubernetes.io/app-root | 定义Controller必须重定向的应用程序根,如果他在'/'上下文中 | 串 |
| nginx.ingress.kubernetes/use-regex | 指示Ingress上定义的路径是否使用正则表达式 | 布尔 |
示例
| |
Reference
16.6 - 52.kubernetes02
存储
服务分类
- 有状态服务:DBMS
- 无状态服务:LVS、APACHE
configMap
配置文件注册中心
注意:nginx不支持热更新,需要重启
创建configMap
使用目录创建
Expand/Collapse Code Block
| |
--from-file:指定在目录下的所有文件都会被用在ConfigMap里面创建一个键值对,键的名字就是文件名,值就是文件的内容。
使用文件创建
指定为一个文件就可以从单个文件创建ConfigMap
| |
--from-file参数可以使用多次,可以使用两次分别指定上个实例中的两个配置文件,效果跟指定整个目录是一样的
使用字面值创建
利用--from-literal参数传递配置信息,该参数可以使用多次,如下:
| |
Pod中使用configMap
ConfigMap来替代环境变量
| |
| |
将配置值注入到Pod的环境变量中Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: v1
kind: Pod
metadata:
name: dapi-test-pod
spec:
containers:
- name: test-container
image: xx:v1
command: ["/bin/sh", "-c", "env"]
env:
- name: SPECIAL_LEVEL_KEY
valueFrom:
configMapKeyRef:
name: special-config
key: special.how
- name: SPECIAL_TYPE_KEY
valueFrom:
configMapKeyRef:
name: special-config
key: special.type
envFrom:
- configMapRef:
name: env-config
restartPolicy: Never
ConfigMap设置命令行参数
基本同上,导入变量一样,只是在启动命令中使用而已
Expand/Collapse Code Block
| |
通过数据卷插件使用ConfigMap
在数据卷里面使用ConfigMap,有不同的选项。最基本的就是将文件填入数据卷,在这个文件中,键就是文件名,键值就是文件内容
| |
验证
| |
configMap热更新
实现演示
Expand/Collapse Code Block
| |
| |
修改ConfigMap
| |
特别注意: configMap如果是以ENV的方式挂载至容器,修改configMap并不会实现热更新
更新触发说明
更新ConfigMap目前并不会触发相关Pod的滚动更新,可以通过修改pod annotations的方式强制触发滚动更新
| |
这个例子里在.spec.template.metadata.annotations 中添加 version/config,每次通过修改version/config 来触发滚动更新。 更新ConfigMap后
1、使用该ConfigMap挂载的Env不会同步更新
2、使用该ConfigMap挂载的Volume中的数据需要一段时间(实测大概10秒)才能同步更新
Secret
定义概念
Secret解决了密码、token、密钥等敏感数据的配置问题,而不需要把这些敏感数据暴露到镜像或者Pod Spec中。Secret可以以Volume或者环境变量的方式使用。
Secret有以下3种类型
Service Account
用来访问Kubernetes API,由Kubernetes自动创建,并且会自动挂载到Pod的 /run/secrets/kubernetes.io/serviceaccount 目录中
Opaque Secret
base64编码格式的Secret,用来存储密码、密钥等
创建说明
Opaque类型的数据是一个map类型,要求value是base64编码格式
| |
secrets.yml
| |
使用方式
1、将Secret挂载到Volume中
| |
操作:
| |
2、将Secret导出到环境变量中Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: extension/v1beta1
kind: Deployment
metadata:
name: pod-deployment1
spec:
replicas: 2
template:
metadata:
labels:
app: pod-deployment1
spec:
containers:
- name: pod-1
iamge: xx:v1
ports:
- containerPort: 80
env:
- name: TEST_USER
valueFrom:
secretKeyRef:
name: mysecret
key: username
- name: TEST_PASSWORD
valueFrom:
secretKeyRef:
name: mysecret
key: password
kubernetes.io/dockerconfigjson
用来存储私有docker registry的认证信息
使用Kubectl创建docker registry认证的secret
| |
修改镜像名并推送
| |
拉取私有镜像
| |
创建Pod,通过 imagePullSecrets 来引用刚创建的 myregistrykey
| |
volume
容器磁盘上的文件的声明周期是短暂的,这就使得在容器中运行重要应用时会出现一些问题。首先,当容器崩溃时,kubelet会重启它,但是容器中的文件将丢失——容器以干净的状态(镜像最初的状态)重新启动(与docker不一样!docker会保留数据)。其次,在Pod中同时运行多个容器时,这些容器之间通常需要共享文件。Kubernetes中的Volume抽象就很好的解决了这些问题。
背景
Kubernetes中的卷有明确的寿命一一与封装它的Pod相同。所以, 卷的生命比Pod中的所有容器都长, 当这个容器重启时数据仍然得以保存。当然, 当Pod不再存在时, 卷也封不复存在。也许更重要的是, Kubernetes支持多种类型的卷, Pod可以同时使用任意数量的卷

卷的类型
Kubernetes支持以下类型的卷:
- awsElasticBlockStore、azureDisk、azureFile、cephfs、csi、dowwardAPI、emptyDir
- fc、flocker、gcePersistentDisk、gitRepo、glusterfs、hostPath、iscsi、local、nfs
- ersistentVolumeClaim、projected、portworxVolume、quobyte、rbd、scaleIO、secret
- storage、vsphereVolume
emptyDir
当Pod被分配给节点时,首先创建emptyDir卷,并且只要该Pod在该节点上运行,该卷就会存在。正如卷的名字所述, 它最初是空的。Pod中的容器可以读取和写入enptyDir卷中的相同文件, 尽管该卷可以挂载到每个容器中的相同或不同路径上。当出于任何原因从节点中删除Pod时, enptyoir中的数据特被永久删除
注意: 容器崩溃不会从节点中移除pod,因此emptyDir卷中的数据在容器崩溃时是安全的
emptyDir的用法:
1、暂存空间,例如用于基于磁盘的合并排序
2、用作长时间计算奔溃恢复时的检查点
3、Web服务器容器提供数据时,保存内容管理器容器提取的文件
| |
hostPath
卷将主机节点的文件系统中的文件或目录挂载到集群中
配置方式简单,需要手动指定pod跑在某个固定节点上。仅供单节点测试使用,不适用与多节点集群。minikube提供了hostPath存储。
hostPath的用途如下:
1、运行需要访问Docker内部的容器;使用 /var/lib/docker 的hostPath
2、在容器中运行cAdvisor;使用 /dev/cgroups 的hostPath
除了所需的path属性之外,用户还可以为 hostPath 卷指定 type
| 值 | 行为 |
|---|---|
| 空字符串(默认)用于向后兼容,意味着在挂载hostPath卷之前不会执行任何检查 | |
| DirectoryOrCreate | 如果在给定的路径上没有任何东西存在,那么将根据需要在那里创建一个空目录,权限设置为0755,与kubelet具有相同的组合所有权 |
| FileOrCreate | 如果在给定的路径上没有任何东西存在,那么会根据需要创建一个空文件,权限设置为0644,与kubelet具有相同的组和所有权 |
| File | 给定的路径下必须存在文件 |
| Socket | 给定的路径下必须存在UNIX套接字 |
| CharDevice | 给定的路径下必须存在字符设备 |
| BlockDevice | 给定的另下必须存在块设备 |
使用这种卷类型时请注意,因为:
1、由于每个节点上的文件都不同,具有相同配置(例如从 podTemplate 创建的)的Pod在不同节点上的行为可能会有所不同
2、当Kubernetes按照计划添加资源感知调度时,将无法考虑hostPath使用的资源
3、当底层主机上创建的文件或目录只能由root写入。您需要在特权容器中以root身份运行进程,或修改主机上的文件以便写入hostPath卷
demo
| |
PV(Persistent Volume)
概念解释
PV(PersistentVolume)
是由管理员设置的存储,它是集群的一部分。就像节点时集群中的资源一样,PV也是集群中的资源。PV是Volume之类的卷插件,但具有独立于使用PV的Pod的生命周期。此API对象包含存储实现的细节,即NFS、iSCSI或特定于云供应商的存储系统
PVC(PersistentVolumeClaim)
是用户存储的请求。与Pod相似。Pod消耗节点资源,PVC消耗PV资源。Pod可以请求特定级别的资源(CPU和内存)。声明可以请求特定的大小和访问模式(例如,可以以读写一次货只读多次模式挂载)
PV&PVC
静态pv
集群管理员创建一些PV。它们带有可供集群用户使用的实际存储的细节。它们存在于Kubernetes API中,可用于消费
动态pv(了解即可)
当管理员创建的静态pv都不匹配用户的PersistentVolumeClaim时,集群可能会尝试动态地为PVC创建卷。此配置基于StorageClasses:PVC必须请求[存储类],并且管理员必须创建并配置该类才能进行动态创建。声明该类为""可以有效地禁用其动态配置
要启用基于存储级别的动态存储配置,集群管理员需要启用API server上的DefaultStorageClass[准入控制器]。例如,通过确保DefaultStorageClass位于API server组件的--adminsssion-control标志,使用逗号分隔的有序值列表中,可以完成此操作
绑定
master中的控制环路监视新的PVC,寻找匹配的PV(如果可能),并将它们绑定在一起。如果为新的PVC动态调配PV,则该环路将始终将该PV绑定到PVC。否则,用户总会德奥它们所请求的存储,但是容量可能超出要求的数量。一旦PV和PVC绑定后,PersistentVolumeClaim绑定是排他性的,不管它们是如何绑定的。PVC跟PV绑定是一对一的映射。
持久化卷声明的保护
PVC保护的目的是确保由pod正在使用的PVC不会从系统中移除,因为如果被移除的话可能会导致数据丢失
注意:当pod状态为Pending并且pod已经分配给节点或pod为Running状态时,pvc处于活动状态
当启用PVC保护alpha功能时,如果用户删除了一个pod正在使用的PVC,则该PVC不会被立即删除。PVC的删除将被推迟,直到PVC不再被任何pod使用。
持久卷演示代码
| |
后端类型
持久化卷类型
PersistentVolume类型以插件形式实现。Kubernetes目前支持以下插件类型:
1、GCEPersistentDisk、AWSElasticBlockStrore、AzureFile AzureDisk FC(Fibre Channel)
2、FlexVolume Flocker NFS iSCSI RBD(Ceph Block Device) CephFS
3、Cinder(OpenStack block storage) Glusterfs VsphereVolume Quobyte Volumes
4、HostPath VMware Photon Portworx Volumes ScaleIO Volumes StorageOS
PV访问模式说明
PersistentVolume可以以资源提供者支持的任何方式挂载到主机上。如下表所示,供应商具有不同的功能, 每个PV的访问模式都将被设置为该卷支持的特定模式。例如,NFS可以支持多个读/写客户端,但特定的NFSPV可能以只读方式导出到服务器上。每个PV都有一套自己的用来描述特定功能的访问模式
1、ReadWriteOnce一一该卷可以被单个节点以读/写模式挂载
2、ReadOnlyMany一一该卷可以被多个节点以只读模式挂载
3、ReadWriteMany一一该卷可以被多个节点以读/写模式挂载
在命令行中, 访问模式缩写为:
1、RWO - ReadWriteOnce
2、ROX - ReadOnlyMany
3、RWX - ReadWriteMany
一个卷一次只能使用一种访问模式挂载,即使它支持很多访问模式。例如, GCEpersistentDisk可以由单个节点作为ReadWriteOnce模式挂载,或由多个节点以ReadOnlyMany模式挂载,但不能同时挂载。
回收策略
1、Retain(保留)——手动回收
2、Recycle(回收)——基本擦除(rm -rf /thevolume/*)
3、Delete(删除)——关联的存储资产(例如AWS EBS、GCE PD、Azure Disk和OpenStack Cinder卷)将被删除
当前,只有NFS和HostPath支持回收策略。AWS EBS、GCE PD、Azure Disk和OpenStack Cinder卷支持删除策略。
状态
卷可以处于以下的某种状态:
1、Available(可用) 一一一块空闲资源还没有被任何声明绑定
2、Bound(已绑定) 一一卷已经被声明绑定
3、Released(已释放) 一一声明被删除, 但是资源还未被集群重新声明
4、Failed(失败) 一一该卷的自动回收失败
命令行会显示绑定到PV的PVC的名称
实例演示
持久化演示说明-NFS
整体结构

1、安装 NFS 服务器
| |
测试:
| |
2、部署PV
| |
创建pv
| |
3、创建服务并使用PVCExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
apiVersion: v1
kind: Service
metadata:
name: nginx
labels:
app: nginx
spec:
ports:
- port: 80
name: web
clusterIP: None
selector:
app: nginx
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
selector:
matchLabels:
app: nginx
replicas: 3
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: xxx
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: "nfs"
resources:
requests:
storage: 1Gi
操作:
| |
KFStatefulSet
1、匹配Pod name(网络标识) 的模式为: $(statefulset名称) -$(序号) , 比如上面的示例: web-0, web-1, web-2
2、StatefulSet为每个Pod副本创建了一个DNS域名, 这个域名的格式为: $(podname) .(headless server name),也就意味着服务间是通过Pod域名来通信而非Pod IP,因为当Pod所在Node发生故障时,Pod会被迁移到其它Node上,PodIP会发生变化,但是Pod域名不会有变化
3、StatefulSet使用Headless服务来控制Pod的域名,这个域名的FQDN为:(servicename).
(namespacej).svc.cluster.local,其中, “cluster.loca”指的是集群的域名
4、根据volumeClaimTemplates,为每个Pod创建一个pvc,pvc的命名规则匹配模式:
(volumeClaimTemplates.name)-(pod_name),比如上面的volumeMounts.name=www,pod
name=web-[0-2],因此创建出来的PVC是www-web-0、www-web-1、www-web-2
5、删除Pod不会删除其pvc,手动删除pvc将自动释放pv
Statefulset的启停顺序:
1、有序部署:部署StatefulSet时,如果有多个Pod副本,它们会被顺序地创建(从0到N-1) 并且,在下一个Pod运行之前所有之前的Pod必须都是Running和Ready状态。
2、有序删除:当Pod被删除时, 它们被终止的顺序是从N-1到0。
3、有序扩展:当对Pod执行扩展操作时,与部署一样,它前面的Pod必须都处于Running和Ready状态。
StatefulSet使用场景:
1、稳定的持久化部署,即Pod重新调度后还是能访问到相同的持久化数据,基于PVC来实现
2、稳定的网络标识符,即Pod重新掉浮后其PodName和HostName不变
3、有序部署,有序扩展,基于init containers来实现
4、有序收缩
调度器
调度器概念
Scheduler是kubernetes的调度器,主要的任务是把定义的pod分配到集群的节点上。听起来非常简单,但有很多要考虑的问题:
1、公平:如何保证每个节点都能被分配资源
2、资源高效利用:集群所有资源最大化被使用
3、效率:调度的性能要好,能够尽快地对大批量的pod完成调度工作
4、灵活:允许用户根据自己的需求控制调度的逻辑
Sheduler是作为单独的程序运行的,启动之后会一直监听APIServer,获取podspec.NodeNanme为空的pod,对每个pod都会创建一个binding, 表明该pod应该放到哪个节点上
调度过程
调度分为几个部分:首先是过滤摇不满足条件的节点,这个过程称为predicate;然后对通过的节点按照优先级排序,这个是priority;最后从中选择优先级最高的节点。如果中间任何一步骤有错误, 就直接返回错误
Predicate有一系列的算法可以使用:
1、PodFitsResources:节点上剩余的资源是否大于pod请求的资源
2、PodFitsHost:如果pod指定了NodeName,检查节点名称是否和NodeName匹配
3、PodFitsHostPorts:节点上已经使用的port是否和pod申请的port冲突
4、PodSelectormatches:过滤掉和pod指定的label不匹配的节点
5、Nooiskconflict:已经mount的volume和pod指定的volume不冲突,除非它们都是只读
如果在predicate过程中没有合适的节点,pod会一直在pending状态,不断重试调度,直到有节点满足条件。经过这个步骤,如果有多个节点满足条件,就继续priorities过程:按照优先级大小对节点排序。
优先级由一系列键值对组成,销是该优先级顶的名称,值是它的权重(该项的重要性) 。这些优先级选项包括:
1、LeastRequestedPriority:通过计算CPU和Memory的使用率来决定权重,使用率越低权重越高。换句话说,这个优先级指标倾向于资源使用比例更低的节点
2、BalancedResourceAllocation:节点上CPU和Memory使用率越接近,权重越高。这个应该和上面的一起使用,不应该单独使用
调度亲和性
键值运算关系
1、In:label的值在某个列表中
2、NotIn:label的值不在某个列表中
3、Gt:label的值大于某个值
4、Lt:label的值小于某个值
5、Exists:某个label存在
6、DoesNotExist:某个label不存在
如果nodeSelectorTerms下面有多个选项的话,满足任何一个条件就可以了;如果matchExpressions有多个选项的话,则必须同时满足这些条件才能正常调度
亲和性和反亲和性调度策略比较如下:
| 调度策略 | 匹配标签 | 操作符 | 拓扑域 | 调度目标 |
|---|---|---|---|---|
| nodeAffinity | 主机 | In, NotIn, Exists, DoesNotExist, Gt, Lt | 否 | 指定主机 |
| podAffinity | POD | In, NotIn, Exists, DoesNotExist | 是 | POD与指定POD同一拓扑域 |
| podAffinity | POD | In, NotIn, Exists, DoesNotExist | 是 | POD与指定POD不在同一拓扑域 |
节点亲和性
pod.spec.nodeAffinity
1、preferredDuringSchedulingIgnoreDuringExecution:软策略
2、requireDuringSchedulingIgnoreDuringExecution:硬策略
requireDuringSchedulingIgnoreDuringExecution
Expand/Collapse Code Block
| |
辅助信息查询
| |
preferredDuringSchedulingIgnoredDuringExecution
Expand/Collapse Code Block
| |
当然软硬策略是可以一起使用的,略
Pod亲和性
pod.spec.affinity.podAffinity/podAntiAffinity
1、preferredDuringSchedulingIgnoreDuringExecution:软策略
2、requireDuringSchedulingIgnoreDuringExecution:硬策略
Expand/Collapse Code Block
| |
污点和容忍
节点亲和性,是pod的一种属性(偏好或硬性要求),它使pod被吸引到一类特定的节点,Taint则相反,它使节点能够排斥一类特定的pod
Taint和toleration相互配合,可以用来避免pod被分配到不合适的节点上。每个节点上都可以应用一个或多个taint,这表示对于那些不能容忍这些taint的pod,是不会被该节点接受的。如果将toleration应用于pod上,则表示这些pod可以(但不要求)被调度到具有匹配taint的节点上。
污点(Taint)
1、污点的组成
使用kubectl taint命令可以给某个Node节点设置污点,Node被设置上污点之后就和Pod之间存在了一种相斥的关系,可以让Node拒绝Pod的调度执行,甚至将Node已经存在的Pod驱逐出去
每个污点的组成如下:
| |
每个污点有一个key和value作为污点的标签,其中value可以为空,effect描述污点的作用。当前taint effect支持如下三个选项: 1、NoSchedule:表示k8s将不会将Pod调度到具有该污点的Node上
2、PreferNoSchedule:表示k8s将尽量避免将Pod调度到具有该污点的Node上
3、NoExecute:表示k8s将不会将Pod调度到具有该污点的Node上已经存在的Pod驱逐出去
污点的设置、查看和去除
| |
容忍(Tolerations)
设置了污点的Node将根据taint的effect: NoSchedule、PreferNoSchedule、NoExecute
和Pod之间产生互斥的关系,Pod将在一定程度上不会被调度到Node上。但我们可以在Pod上设
置容忍(Toleration) ,意思是设置了容忍的Pod将可以容忍污点的存在,可以被调度到存在污点的Node上
pod.spec.tolerations
| |
1)其中key, vaule, effect要与Node上设置的taint保持一致 2)operator的值为Exists将会忽略value值
3)tolerationSeconds用于描述当Pod需要被驱逐时可以在Pod上继续保留运行的时间
1、当报振定key值时, 表示容志所有的污点key:
| |
2、当不指定effect值时, 表示容忍所有的污点作用
| |
3、有多个Master存在时, 防止资源浪费, 可以如下设置
| |
固定节点调度
1、Pod.spec.nodeName将Pod直接调度到指定的Node节点上,会跳过Scheduler的调度策
略, 该匹配规则是强制匹配
| |
2、Pod.spec.nodeSelector:通过Kubernetes的label-selector机制选择节点,由调度器调度策略匹配label,而后调度Pod到目标节点,该匹配规则属于强制约束
| |
集群安全机制
机制说明
Kubernetes作为一个分布式集群的管理工具,保证集群的安全性是其一个重要的任务。API Server是集群内部各个组件通信的中介,也是外部控制的入口。所以Kubernetes的安全机制基本就是围绕保护API Server来设计的。Kubernetes使用了认证(Authentication)、鉴权(Authorization)、准入控制(Admission Control)三步来保证API Server的安全。
认证
HTTP Token
通过一个Token来识别合法用户。
HTTP Token的认证使用一个很长的特殊编码方式的并且难以被模仿的字符串-Token来表达客户的一种方式。Token是一个很长的很复杂的字符串,每一个Token对应一个用户名存储在API Server能访问的文件中。当客户端发起API调度请求时,需要在HTTP Header里放入Token
HTTP Base
通过用户名+密码的方式认证
用户名+密码用BASE64算法进行编码后的字符串放在HTTP Request中的Heather Authorization域里发送给服务端,服务端收到后进行编码,获取用户名及密码。
上面两种方式的问题:服务端没有被认证,只认证了客户端
HTTPS
基于CA根证书签名的客户端身份认证方式
1、HTTPS证书认证

2、需要认证的节点
两种类型:
1、Kubernetes组件对API Server的访问:kubectl、Controller Manager、Scheduler、kubelet、kube-proxy
2、Kubernetes管理的Pod对容器的访问:Pod(dashborad也是以Pod形式运行)
安全性说明
1、Controller Manager、Scheduler与API Server在同一台机器,所以直接使用API Server的非安全端口访问,--insecure-bind-address=127.0.0.1
2、kubectl、kubelet、kube-proxy访问API Server就都需要证书进行HTTPS双向认证
证书颁发
1、手动签发:通过k8s集群的跟CA进行签发HTTPS证书
2、自动签发:kubelet首次访问API Server时,使用token做认证,通过后,Controller Manager会为kubelet生成一个证书,以后的访问都是用证书做认证了
3、kubeconfig
kubeconfig文件包含集群参数(CA证书、API Server地址),客户端参数(上面生成的证书和私钥),集群context信息(集群名称、用户名)。Kubernetes组件通过启动时指定不同的kubeconfig文件可以切换到不同的集群
4、ServiceAccount
Pod中的容器访问API Server。因为Pod的创建、销毁是动态的,所以要为它手动生成证书就不可行了。Kubernetes使用Service Account解决Pod访问API Server的认证问题
5、Secret与SA的关系
Kubernetes设计了一种资源对象叫做Secret,分为两类,一种是用于ServiceAccount的service-accont-token,另一种是用于保存用户自定义保密信息的Opaque。ServiceAccount中用到包含三个部分:Token、ca.crt、namespace
1)token是使用API Server私钥签名的JWT。用于访问API Server时,Server端认证
2)ca.crt,根证书。用于Client端验证API Server发送的证书
3)namespace,标识这个serveice-account-token的作用域名空间
Json web token(JWT),是为了在网络应用环境间船体声明而执行的一种基于JSON的开放标准(RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登陆(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间船体被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证、也可被加密。
| |
默认情况下,每个namespace都会有一个ServiceAccount,如果Pod在创建时没有指定ServiceAccount,就会使用Pod所属的namespace的ServiceAccount 默认挂载目录:/run/secrets/kubernetes.io/serviceaccount/
鉴权
上面认证过程,只是确认通信的双方都确认了对方是可信的,可以相互通信。而鉴权是确定请求方有哪些资源的权限。API Server目前支持以下几种授权策略(通过API Server的启动参数"--authorization-mode"设置)
AlwaysDeny
表示拒绝所有的请求,一般用于测试
AlwaysAllow
允许接收所有秦秋,如果集群不需要授权流程,则可以采用该策略
ABAC
(Attribute-Based Access Control):基于属性的访问控制,表示使用用户配置的授权规则对用户请求进行匹配和控制。并且修改后需要重启API Server。(需要定义很多属性)
Webbook
通过调用外部REST服务对用户进行授权
RBAC
(Role-Based Access Control):基于角色的访问控制,现行默认规则
RBAC授权模式
在Kubernetes 1.5中引入,现行版本成为默认标准。相对于其它访问控制方式,拥有以下优势
1)对集群中的资源和非资源均拥有完整的覆盖
2)整个RBAC完全由几个API对象完成,同其它API对象一样,可以用kubectl或API进行操作
3)可以在运行时进行调整,无需重启API Server
1、RBAC的API资源对象说明
RBAC引入了4个新的顶级资源对象:Role、ClusterRole、RoleBinding、ClusterRoleBinding,4种对象类型均可以通过kubectl与API操作

需要注意的是Kubernetes并不会提供用户管理,那么User、Group、ServiceAccount指定的用户又是从哪里来的呢?Kubernetes组件(kubectl、kube-proxy)或是其他自定义的用户在想CA申请证书时,需要提供一个证书请求文件
| |
API Server会把客户端证书的CN字段作为User,把names.O字段作为Group kubelet使用TLS Bootstaping认证时,API Server可以使用Bootstrap Tokens或者Token authentication file 验证 =token,无论哪一种,Kubernetes都会为token绑定一个默认的User和Group
Pod使用ServiceAccount认证时,service-account-token中的JWT会保存User信息
有了用户信息,再创建一对角色/角色绑定(集群角色/集群角色绑定)资源对象,就可以完成权限绑定了
Role and ClusterRole
在RBAC API中,Role表示一组规则权限,权限只会增加(累加权限),不存在一个资源一开始就有很多权限而通过RBAC对其进行减少的操作;Role可以定义一个namespace钟,如果想要跨namespace中,如果想要跨namespace则可以创建ClusterRole
| |
ClusterRole具有与Role相同的权限角色控制能力,不同的是ClusterRole是集群级别的,ClusterRole可以用于: 1、集群级别的资源控制(例如node访问权限)
2、非资源型endpoints(例如 /healthz 访问)
3、所有命名空间资源控制(例如pods)
| |
RoleBinding and ClusterRoleBindind
RoleBinding可以将角色中定义的权限授予用户或用户组,RoleBinding包含一组权限列表(subjects),权限列表中包含有不同形式的待授予权限资源类型(users, groups, or service accounts);RoleBinding同样包含对被Bind的Role引用;RoleBinding适用于某个命名空间内授权,而ClusterRoleBinding适用于集群范围内的授权
将default命名空间的pod-reader Role授予jane用户,此后jane用户在default命名空间中将具有pod-reader的权限
| |
RoleBinding同样可以引用ClusterRole来对当前namespaces内用户、用户组或ServiceAccount进行授权,这种操作允许操作集群管理员在整个集群内定义一些通用的ClusterRole,然后在不同的namespace中使用RoleBinding来引用 例如,以下RoleBinding引用了一个ClusterRole,这个ClusterRole具有整个集群内对secrets的访问权限;但是其授权用户dave只能访问development空间中的secrets(因为RoleBinding定义在development命名空间)
| |
使用ClusterRoleBinding可以对整个集群中的所有命名空间资源权限进行授权,以下ClusterRoleBinding示例展示了授权manager组内所有用户在全部命名空间中对secrets进行访问
| |
Resources
Kubernetes集群内一些资源一般以其名称字符串来表示,这些字符串一般会在API的URL地址中出现;同时某些资源也会包含子资源,例如logs资源就属于pods的子资源,API中URL样例如下:
| |
如果要在RBAC授权模型中控制这些子资源的访问权限,可以通过 / 分隔符来实现,以下是一个定义pods资源logs访问权限的Role定义样例
| |
to Subjects
RoleBinding和ClusterRoleBinding可以将Role绑定到Subjects;Subjects可以是groups、users或者service accounts
Subjects中Users使用字符串表示,它可以是一个普通的名字字符串,如“alice”;也可以是email格式的邮箱地址;甚至是一组字符串形式的数字ID。但是Users的前缀system: 是系统保留的,集群管理员应该确保普通用户不会使用这个前缀格式
Groups书写格式与Users相同,都为一个字符串,并且没有特定的格式要求;同样system: 前缀为系统保留
实验:创建系统用户管理名称空间
| |
证书工具Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 下载证书生成工具
wget https://pkg.cfssl.org/R1.2/cfssl_linux-amd64
mv cfssl_linux-amd64 /usr/local/bin/sfssl
wget https://pkg.cfssl.org/R1.2/cfssljson_linux-amd64
mv cfssljson_linux-amd64 /usr/local/bin/cfssljson
wget https://pkg.cfssl.org/R1.2/cfssl-certinfo_linux-amd64
mv cfssl-certinfo_linux-amd64 /usr/local/bin/cfssl-certinfo
cfssl gencert -ca=ca.crt -ca-key=ca.key -profile=kubernetes /root/devuser-csr.json | \
cfssljson -bare devuser
# 设置集群参数
export KUBE_APISERVER="https://172.20.0.113.6443"
kubectl config set-cluster kubernetes \
--certificate-authority=/etc/kubernetes/ssl/ca.pem \
--embed-certs=true \
--server=${KUBE_APISERVER} \
--kubeconfig=devuser.kubeconfig
# 设置客户端认证参数
kubectl config set-credentials devuser \
--client-certificate=/etc/kubernetes/ssl/devuser.pem \
--client-key=/etc/kubernetes/ssl/devuser-key.pem \
--embed-certs=true \
--kubeconfig=devuser.kubeconfig
# 会生成devuser.kubeconfig文件
# 设置上下文参数
kubectl config set-context kubernetes \
--cluster=kubernetes \
--user=devuser \
--namespace=dev \
--kubeconfig=devuser.kubeconfig
kubectl create namespace dev
# 设置默认上下文
kubectl config use-context kubernetes --kubeconfig=devuser.kubeconfig
cp -f ./devuser.kubeconfig /root/.kube/config
kubectl create rolebinding devuser-admin-binding --clusterrole=admin \
--user=devuser --namespace=dev
kubectl get pod --all-namespace -o wide | grep nginx
kubectl get pod -n default
准入控制
准入控制是API Server的插件集合,通过添加不同的插件,实现额外的准入控制规则。甚至于API Server的一些主要的功能都需要通过Admission Controllers实现,比如ServiceAccount
官方文档上有一份针对不同版本的准入控制器推荐列表,其中最新的1.14的推荐列表是:
| |
列举几个插件的功能: 1、NamespaceLifecycle
防止不存在namespace上创建对象,防止删除系统阈值namespace,删除namespace是,连带删除它的所有资源对象
2、LimitRanger
确保请求的资源不会超过资源所在Namespace的LimitRange的限制
3、ServiceAccount
实现了自动化添加ServiceAccount
4、ResourceQuota
确保请求的资源不会超过资源的ResourceQuota限制
HELM
在没使用helm之前,想Kubernetes部署应用,要一次部署deployment、svc等,步骤较繁琐。况且随着很多项目微服务话,复杂的应用在容器中部署以及管理显得较为复杂,helm通过打包的方式,支持发布的版本管理和控制,很大程序上简化了Kubernetes应用的部署和管理
Helm本质就是让K8S的应用管理(Deployment、Service等)可配置,能动态生成。通过动态生成K8S资源清单文件(deployment.yaml、service.yaml)。然后调用kubectl自动执行K8S资源部署
Helm是官方提供的类似于Linux的YUM的包管理器,是部署环境的流程封装。Helm有两个重要的概念:chart和release
1、chart是创建一个因公的信息集合,包括各种Kubernetes对象的配置模板、参数定义、依赖关系、文档说明等。chart是应用部署的自包含逻辑单元。可以将chart想象成apt、yum中的软件安装包
2、release是chart的运行实例,代表了一个正在运行的应用。当chart被安装到Kubernetes集群,就生成一个release。chart能够多次安装到同一个集群,每次安装都是一个release
Helm包含两个组件:Helm客户端和Tiller服务器

Helm客户端负责chart和release的创建和管理以及和Tiller的交互。Tiller服务器运行在Kubernetes集群中,它会处理Helm客户端的请求,与Kubernetes API Server交互。
HELM部署实例
下载helm命令行工具到master节点node1的 /usr/local/bin下
| |
为了安装服务端tiller,还需要再这台机器上配置好kubectl工具和kubeconfig文件,确保kubectl工具可以在这台机器上访问apiserver且正常使用。这里的node1节点以及配置好了kubectl 因为Kubernetes APIServer开启了RBAC的访问控制,所以需要创建tiller使用的service account: tiller并分配合适的角色给它。详细内容可以查看helm文档中的Role-based Access Control(https://docs.helm.sh/using_helm/#role-based-access-control)。这里简单起见直接分配cluster-admin集群内置的Cluster Role给它。创建rbac-config.yaml文件:
| |
操作
| |
HELM自定义模板
| |
| |
Expand/Collapse Code Block
| |
| |
Expand/Collapse Code Block
| |
Debug
| |
HELM部署dashboard
kubernetes-dashboard.yaml
| |
| |
| |
metrics-server
HPA演示
资源限制
pod
命名空间
Prometheus
EFK
运维
Kubeadm源码修改
Kubernetes高可用构建
Reference
17 - Cloud
Introduction
云相关知识,如云计算等
18 - 安全
Introduction
安全漏洞相关
18.1 - fastjson远程执行漏洞分析
背景
阿里巴巴的fastjson作为java语言的json解析包,因为其易用性在国内被广泛应用。但是其安全性一直被诟病。因此,本文对其多次安全漏洞进行了分析总结。
fastjson安全特性
fastjson存在一个特殊的key:@type。它可以指定将json字符串反序列化为value中指定的任意类。形如:
| |
能通过fastjson的JSON.parseObject方法反序列化为com.xx.Hello DTO类的一个实例对象。 而fastjson的诸多安全漏洞,都是围绕@type产生
漏洞案例
详细代码请参考:java-demo fastjson
服务端代码
1、rmi server
| |
2、hello.java
| |
1、本地调用示例
| |
原理分析
待补充
2、远程调用示例
| |
原理分析
待补充
3、Lxx;漏洞
| |
原理分析
待补充
4、LLxx;;漏洞
| |
原理分析
待补充
5、[漏洞
| |
原理分析
待补充
6、缓存漏洞
| |
原理分析
待补充
Reference
18.2 - Log4j2远程执行漏洞
背景
都2021年了,居然还有这种简单粗暴直接替换占位符的安全问题,真的难以想象。比之前fastjson的漏洞还要大、还要容易。
问题原因
占位符替换机制
log4j提供了占位符的功能,如下:
| |
会把 ${java:os} 替换成了 System.getProperties("os")
jndi占位符替换
离谱的是 log4j 还提供了关于 jndi 的占位符。如:
| |
影响范围
1、log4j 2.x.x版本(包括直接、间接依赖;1.x版本不受影响)
2、FastJson < 1.2.69 版本
漏洞复现
更多源码参考:java-demo logsafe
jndi服务
Expand/Collapse Code Block
| |
用户程序
| |
触发用户程序
注意:务必关闭服务端和客户端的全局代理模式!
启动用户程序发现报错:
| |
因为本机用的是JDK8,添加了一个新的属性默认为false,需要在用户侧手动开启。添加如下参数,IDEA中配置"VM Options"
| |
重新启动,发现成功入侵!
解决方法
更新log4j版本
将 log4j 到最新版本 2.15 及以上 ,亲测有效
添加jvm参数
亲测不行!
添加jvm参数,禁止占位符替换
| |
或创建 "log4j2.component.properties" 文件,文件中加入"log4j2.formatMsgNoLookups=true"
升级到java8
Java 8 中添加了一个新的属性 com.sun.jndi.rmi.object.trustURLCodebase,这是一个 boolean 类型。默认值是 false,在使用 jndi 时会抛出一个报错阻止加载远程服务器提供的代码。
删除jar包class文件
删除 log4j-core-*.jar 中 org/apache/logging/log4j/core/lookup/JndiLookup.class 这个文件,彻底进行不了 jndi
可行性较小
Reference
19 - 大数据
Introduction
大数据相关,如Hadoop/Spark/HDFS/HIVE/HBASE/Flink等
19.1 - 初探Hbase
HBase是什么
HBase是一种构建在HDFS之上的分布式键值存储系统。
HBase 是Google Bigtable 的开源实现。HBase不同于一般的关系数据库,它是一个适合于非结构化数据存储的数据库。
另一个不同的是HBase基于列的而不是基于行的模式。
适用场景
- 存储大量数据(PB级数据)。
- 高并发写入,瞬间写入量很大(写多,读少)。
- 业务场景简单(无jion,事务), 按单一维度查询(基于rowkey)。
- 非结构化的数据存储,列可以优雅扩展。
不适用场景
- 事务。
- join、group by。
- 除了rowkey之外的复杂查询。
- 高并发,低延迟随机读 。
MT应用场景
- MTtrace
- 云搜
行存储VS列存储

**行式存储:**一张表的数据都是放在一起
**列式存储:**以列为单位聚合数据,不同的列分开存储
| 行式存储 | 列式存储 | |
|---|---|---|
| 优点 | 一行记录的写入是一次完成,消耗的时间比列存储少。 | 读取过程,按列读取不会产生冗余数据。适合列很多,但每次只需查询少数列的场景。 |
| 缺点 | 数据读取时,将一行数据完全读出。存在冗余列 | 需要将一行记录拆分成单列保存,写入次数更多,时间消耗会更大。 |
HBase数据模型
HBase 以列族为区分列式存储数据库。表可以被看成是一个稀疏的行的集合。一个列族是多个column的集合.

物理视图

- anchor 、contents 分别为两个列族,区分存储。
- cnnsi.com 、my.look.ca为列族anchor两个列,html为列族contents的列。
行键
Row Key 是用来检索记录的主键。Row Key 可定义任意字符串,如订单id,事务id。
在HBase 内部,Row Key 保存为字节数组。HBase表的行是按Row Key字典序存储的。
列族
列族一些列的集合,列族必须在表建立的时候声明。column就不需要了,随时可以新建。
在物理上,一个的列族成员在文件系统上都是存储在一起。因为存储优化都是针对列族级别的。
这就意味着,在表设计的时候要考虑一个colimn family的所有成员的是否有相同的访问方式。
Cells和版本
对于同一Row Key 的相同列的多次写操作,使用版本来区分。可以把版本理解每次写入的快照。
A *{row, column, version}*元组就是一个HBase中的一个单元。Cell的内容是不可分割的字节数组,即我们存储和具体的值。
可保存的版本数需要设定,读取这个文件的时候,默认是最近的版本。
操作
主要操作有
Get:指定Row Key查找,性能最高。
Scan :基于Row Key 前缀查找,或全表扫描。
Put:向表增加新行 (如果key是新的) 或更新行 (如果key已经存在)。
Bulk Loading:批量装载,批量装载特性采用 MapReduce 任务,将表数据输出为HBase的内部数据格式,然后可以将产生的存储文件直接装载到运行的集群中。
Delete:从表中删除一行。
TTL
存活时间——列族可以设置TTL秒数,HBase 在超时后将自动删除数据
HBase表设计
rowkey
HBase 的检索都是基于 rowkey,类似sql 里的like 操作,我们需要根据查询场景来合理设置rowkey。参考合理设计hbase rowkey
1:查询最左匹配原则
假设查询包含3个维度:shopId,orderId ,如果将rowkey 定义为: shopId_orderId
则以下维度的查询比较高效
(1) 通过shopId查询
(2) 通过shopId + orderId查询
但通过orderId查询则比较低效,为全表扫描操作
2:避免热点Region
HBase 的行会根据rowkey 拆分到不同的 Region 中。
如果是连续自增性质的rowkey,则相邻rowkey写入到了同一个Region里,产生热点Region。热点Region容易导致读写出现性能瓶颈。
一般的做法是在rowkey 加一个hash前缀。比如hash(shopId)_shopId_orderId
3:避免短键过长
在满足需求的情况下,行键越短越好。
列簇
建议列族不要超过3个,按照访问维度划分。
尽量使列族名小,最好一个字符。(如 "d" 表示 data/default)。
列名
最好还是用短属性名,节约存储空间。
版本数
每个列族可以单独设置,默认是3。按业务需求要合理设置。
数据类型
任何可被转为字节数组的东西可以作为值存入,输入可以是字符串,数字,复杂对象,甚至图像,只要他们能转为字节。
demo
业务场景:评价审核日志收集。
从新增(编辑)一条评价到 诚信审核反馈并储存审核结果完成,定义为一个送审事务。
每个送审事务都有一个唯一标识(transId),在整个事务的各环节包括 :**评价信息存储->评价送审->****诚信审核->审核反馈->**审核结果存储。
针对每个送审事务的各环节,进行日志收集。
查询场景1:根据评价id ,查询所有的审核日志。
查询场景2:根据评价id 和事务id, 查询该事务的审核日志。
表结构定义:
| 参数 | |
|---|---|
| 表名 | 〜〜〜〜 |
| TTL | 永久 |
| Row Key | hash(BizType_BizID)_BizType_BizID_AuditTransID |
| 版本数 | 3(针对同一个审核事务,诚信会有多次反馈结果,可保留多个版本的反馈结果) |
| 列族 | b:基础信息族 e:扩展信息族 |
Columns
| 列族 | 字段名 | 字段名缩写 | 类型 | 含义 | 备注 |
|---|---|---|---|---|---|
| 基础信息族(b) | TransType | ty | tinyint(4) | 事务类型 | 0:用户发起 1:诚信回扫 2:用户申诉 3:用户举报 4:ugc 发起 |
| Version | vs | bigint(20) | 送审版本 | 内容生成时间戳 | |
| EventType0 | et0 | bigint(20) | 事件0发生时间 | 新增完成事件 | |
| EventType1 | et1 | bigint(20) | 事件1发生时间 | 诚信送审完成事件 | |
| EventType2 | et2 | bigint(20) | 事件2发生时间 | 诚信审核反馈事件 | |
| .... | |||||
| AuditResult | tinyint(4) | 诚信审核结果 | 0:无 1:通过 2: 违规 AuditResultEnum | ||
| AuditDetail | string | 诚信审核标签明细 | {处理建议,多标签...} | ||
| AuditTime | bigint(20) | 诚信审核时间 | |||
| .... |
HBase系统架构

Client
包含访问HBase的接口,并维护cache来加快对HBase的访问。
对于管理类操作,Client与HMaster进行RPC。
对于数据读写操作,Client与HRegionServer进行RPC。
Zookeeper
保证任何时候,集群中只有一个master。
实时监控Region server的上线和下线信息。并实时通知给master。
存储HBase的schema和table元数据。
Master
为Region server分配region,负责Region server的负载均衡.
发现失效的Region server并重新分配其上的region。
处理表的建立,删除等操作。
Region Server
维护master分配给他的region,处理对这些region的io请求。
负责切分正在运行过程中变的过大的region。
当用户需要对数据进行读写操作时,需要访问HRegionServer。
Region
table在行的方向上分隔为多个Region。
随着数据不断插入表,当region的某个列族达到一个阈值时就会拆分新的region。
Store
每一个region由一个或多个store组成,hbase会为每个列族建一个store。
HStore存储由两部分组成:MemStore和StoreFiles。 写入数据首先会放在MemStore,当MemStore满了以后会Flush成一个 StoreFile(实际存储在HDHS上的是HFile)。
写操作只要进入内存中就可以立即返回,保证了HBase I/O的高性能。
HFile
HFile就是实际的存储文件。HFile由多个Data Block组成,Data Block是HBase的最小存储单元。
HBase 会基于Data Block的做缓存——BlockCache。
客户的读请求会先到memstore中查数据,若查不到就到blockcache中查,再查不到就会从磁盘上读,并把读入的数据同时放入blockcahce。
HBase的blockcache使用的是LRU(最近最少使用)淘汰策略,当BlockCache的大小达到上限后,会触发缓存淘汰机制,将最老的一批数据淘汰掉。
Reference
19.2 - 大数据框架
大数据架构概述

实时流计算(Spark/Storm/Flink)
大数据(Hadoop/HBase/Hive)
Hadoop
Hadoop生态圈组件
1)Zookeeper:是一个开源的分布式应用程序协调服务,基于zookeeper可以实现同步服务,配置维护,命名服务。
2)Flume:一个高可用的,高可靠的,分布式的海量日志采集、聚合和传输的系统。
3)Hbase:是一个分布式的、面向列的开源数据库, 利用Hadoop HDFS作为其存储系统。
4)Hive:基于Hadoop的一个数据仓库工具,可以将结构化的数据档映射为一张数据库表,并提供简单的sql 查询功能,可以将sql语句转换为MapReduce任务进行运行。
5)Sqoop:将一个关系型数据库中的数据导进到Hadoop的 HDFS中,也可以将HDFS的数据导进到关系型数据库中。
基本概念
block块(物理划分)
block是HDFS中的基本存储单位,hadoop1.x默认大小为64M而hadoop2.x默认块大小为128M。
split分片(逻辑划分)
Hadoop中split划分属于逻辑上的划分,目的只是为了让map task更好地获取数据。split是通过hadoop中的InputFormat接口中的getSplit()方法得到的。
mapreduce运行过程概括5步
1. [input阶段]获取输入数据进行分片作为map的输入
2. [map阶段]过程对某种输入格式的一条记录解析成一条或多条记录
3. [shffle阶段]对中间数据的控制,作为reduce的输入
4. [reduce阶段]对相同key的数据进行合并
5. [output阶段]按照格式输出到指定目录
Map端shuffle
①分区partition
②写入环形内存缓冲区
③spill:执行溢出写
排序sort--->合并combiner--->生成溢出写文件
如果客户端自定义了Combiner(相当于map阶段的reduce),则会在分区排序后到溢写出前自动调用combiner,将相同的key的value相加,这样的好处就是减少溢写到磁盘的数据量。这个过程叫“合并”
④归并merge
Reduce端shuffle
①复制copy
②归并merge
③reduce
MapTask工作机制
(1)Read阶段:Map Task通过用户编写的RecordReader,从输入InputSplit中解析出一个个key/value。
(2)Map阶段:该节点主要是将解析出的key/value交给用户编写map()函数处理,并产生一系列新的key/value。
(3)Collect收集阶段:在用户编写map()函数中,当数据处理完成后,一般会调用OutputCollector.collect()输出结果。在该函数内部,它会将生成的key/value分区(调用Partitioner),并写入一个环形内存缓冲区中。
(4)Spill阶段:即“溢写”,当环形缓冲区满后,MapReduce会将数据写到本地磁盘上,生成一个临时文件。需要注意的是,将数据写入本地磁盘之前,先要对数据进行一次本地排序,并在必要时对数据进行合并、压缩等操作。
(5)Combine阶段:当所有数据处理完成后,MapTask对所有临时文件进行一次合并,以确保最终只会生成一个数据文件。
ReduceTask工作机制
(1)Copy阶段:ReduceTask从各个MapTask上远程拷贝一片数据,并针对某一片数据,如果其大小超过一定阈值,则写到磁盘上,否则直接放到内存中。
(2)Merge阶段:在远程拷贝数据的同时,ReduceTask启动了两个后台线程对内存和磁盘上的文件进行合并,以防止内存使用过多或磁盘上文件过多。
(3)Sort阶段:按照MapReduce语义,用户编写reduce()函数输入数据是按key进行聚集的一组数据。为了将key相同的数据聚在一起,Hadoop采用了基于排序的策略。 由于各个MapTask已经实现对自己的处理结果进行了局部排序,因此,ReduceTask只需对所有数据进行一次归并排序即可。
(4)Reduce阶段:reduce()函数将计算结果写到HDFS上。
Hive
Hive表关联查询,如何解决数据倾斜的问题
1)倾斜原因: map输出数据按key Hash的分配到reduce中,由于key分布不均匀、业务数据本身的特、建表时考虑不周、等原因造成的reduce 上的数据量差异过大。
(1)key分布不均匀;
(2)业务数据本身的特性;
(3)建表时考虑不周;
(4)某些SQL语句本身就有数据倾斜;
如何避免:对于key为空产生的数据倾斜,可以对其赋予一个随机值。
2)解决方案
(1)参数调节:
hive.map.aggr = true
hive.groupby.skewindata=true
生成的查询计划会有两个MR Job。第一个MR Job中,Map的输出结果集合会随机分布到Reduce中,每个Reduce做部分聚合操作,并输出结果,这样处理的结果是相同的Group By Key有可能被分发到不同的Reduce中,从而达到负载均衡的目的;第二个MR Job再根据预处理的数据结果按照Group By Key 分布到 Reduce 中(这个过程可以保证相同的 Group By Key 被分布到同一个Reduce中),最后完成最终的聚合操作。
(2)SQL 语句调节:
① 选用join key分布最均匀的表作为驱动表。做好列裁剪和filter操作,以达到两表做join 的时候,数据量相对变小的效果。
② 大小表Join:
使用map join让小的维度表(1000 条以下的记录条数)先进内存。在map端完成reduce.
③ 大表Join大表:
把空值的key变成一个字符串加上随机数,把倾斜的数据分到不同的reduce上,由于null 值关联不上,处理后并不影响最终结果。
④ count distinct大量相同特殊值:
count distinct 时,将值为空的情况单独处理,如果是计算count distinct,可以不用处理,直接过滤,在最后结果中加1。如果还有其他计算,需要进行group by,可以先将值为空的记录单独处理,再和其他计算结果进行union。
Hive的HSQL转换为MapReduce的过程
HiveSQL ->AST(抽象语法树) -> QB(查询块) ->OperatorTree(操作树)->优化后的操作树->mapreduce任务树->优化后的mapreduce任务树
Hive底层与数据库交互原理
由于Hive的元数据可能要面临不断地更新、修改和读取操作,所以它显然不适合使用Hadoop文件系统进行存储。目前Hive将元数据存储在RDBMS中,比如存储在MySQL、Derby中。元数据信息包括:存在的表、表的列、权限和更多的其他信息。
Hive的两张表关联,使用MapReduce怎么实现
1、如果其中有一张表为小表,直接使用map端join的方式(map端加载小表)进行聚合。
2、如果两张都是大表,那么采用联合key,联合key的第一个组成部分是join on中的公共字段,第二部分是一个flag,0代表表A,1代表表B,由此让Reduce区分客户信息和订单信息;在Mapper中同时处理两张表的信息,将join on公共字段相同的数据划分到同一个分区中,进而传递到一个Reduce中,然后在Reduce中实现聚合。
hive中Sort By,Order By,Cluster By,Distrbute By
order by:会对输入做全局排序,因此只有一个reducer(多个reducer无法保证全局有序)。只有一个reducer,会导致当输入规模较大时,需要较长的计算时间。
sort by:不是全局排序,其在数据进入reducer前完成排序。
distribute by:按照指定的字段对数据进行划分输出到不同的reduce中。
cluster by:除了具有 distribute by 的功能外还兼具 sort by 的功能。
split、coalesce及collect_list函数
split将字符串转化为数组,即:split('a,b,c,d' , ',') ==> ["a","b","c","d"]。
coalesce(T v1, T v2, …) 返回参数中的第一个非空值;如果所有值都为 NULL,那么返回NULL。
collect_list列出该字段所有的值,不去重 => select collect_list(id) from table
Hive保存元数据方式
Hive支持三种不同的元存储服务器,分别为:内嵌式元存储服务器、本地元存储服务器、远程元存储服务器,每种存储方式使用不同的配置参数。
内嵌式元存储主要用于单元测试,在该模式下每次只有一个进程可以连接到元存储,Derby是内嵌式元存储的默认数据库。
在本地模式下,每个Hive客户端都会打开到数据存储的连接并在该连接上请求SQL查询。
在远程模式下,所有的Hive客户端都将打开一个到元数据服务器的连接,该服务器依次查询元数据,元数据服务器和客户端之间使用Thrift协议通信。
Hive内部表和外部表区别
创建表时:创建内部表时,会将数据移动到数据仓库指向的路径;若创建外部表,仅记录数据所在的路径,不对数据的位置做任何改变。
删除表时:在删除表的时候,内部表的元数据和数据会被一起删除, 而外部表只删除元数据,不删除数据。这样外部表相对来说更加安全些,数据组织也更加灵活,方便共享源数据。
Hbase
Hbase特点
- 每个值只出现在一个REGION
- 同一时间一个Region只分配给一个Region服务器
- 行内的mutation操作都是原子的
- put操作要么成功,要么完全失败
当某台region server fail的时候,它管理的region failover到其他region server时,需要根据WAL log(Write-Ahead Logging)来redo(redolog,有一种日志文件叫做重做日志文件),这时候进行redo的region应该是unavailable的,所以hbase降低了可用性,提高了一致性。设想一下,如果redo的region能够响应请求,那么可用性提高了,则必然返回不一致的数据(因为redo可能还没完成),那么hbase就降低一致性来提高可用性了。
Yarn
参考:通俗理解YARN运行原理
流计算对比
第一代计算引擎 mapreduce
mapreduce 作为第一个计算引擎,用于批处理,是计算引擎的先驱,内部支持机器学习但是现在机器学习库不在更新,并且mapreduce 编写十分的耗时,开发效率低,开发时间成本太大,所以很少有企业写mapreduce 来跑程序。
第二代计算引擎 pig/hive
- 作为第二代引擎pig/hive 对hadoop进行了嵌套,其存储基于hdfs,计算基于mr,hive/pig在处理任务时首先会把本身的代码解析为一个个m/r任务,这样就大大的降低了mr的编写编写成本。
- pig 有自己的脚本语言属于,比hive更加的灵活
- hive 属于类sql语法,虽然没有pig灵活,但是对于现在程序员都会sql的世界来说大家更喜欢使用hive
- pig/hive 只支持批处理,且支持机器学习 (hivemall)
第三代计算引擎 spark/storm
随着时代的发展,企业对数据实时处理的需求愈来愈大,所以就出现了storm/spark
- 这两者有着自己的计算模式
- storm属于真正的流式处理,低延迟(ms级延迟),高吞吐,且每条数据都会触发计算。
- spark属于批处理转化为流处理即将流式数据根据时间切分成小批次进行计算,对比与storm而言延迟会高于0.5s(s级延迟),但是性能上的消耗低于storm。“流式计算是批次计算的特例(流式计算是拆分计算的结果)”
第四代计算引擎 flink
- flink2015年出现在apache,后来又被阿里巴巴技术团队进行优化(这里我身为国人为之自豪)为blink,flink支持流式计算也支持的批次处理。
- flink为流式计算而生属于每一条数据触发计算,在性能的消耗低于storm,吞吐量高于storm,延时低于storm,并且比storm更加易于编写。因为storm如果要实现窗口需要自己编写逻辑,但是flink中有窗口方法。
- flink内部支持多种函数,其中包括窗口函数和各种算子(这一点和spark很像,但是在性能和实时上spark是没有办法比较的)
- flink支持仅一次语义保证数据不丢失
- flink支持通过envent time来控制窗口时间,支持乱序时间和时间处理(这点我觉得很厉害)
- 对于批次处理flink的批处理可以理解为 “批次处理是流式处理的特例”(批次计算是流式计算的合并结果)
Spark
RDD
分布式对象集合(有容错机制),本质上是一个只读的分区记录集合。每个 RDD 可以分成多个分区,每个分区就是一个数据集片段。
- 只读:不能修改,只能通过转换操作生成新的 RDD。
- 分布式:可以分布在多台机器上进行并行处理。
- 弹性:计算过程中内存不够时它会和磁盘进行数据交换。
- 基于内存:可以全部或部分缓存在内存中,在多次计算间重用。

Spark Job 默认的调度模式 - FIFO
RDD 特点 - 可分区/可序列化/可持久化
Broadcast - 任何函数调用/是只读的/存储在各个节点
Accumulator - 支持加法/支持数值类型/可并行
Task 数量由 Partition 决定
Task 运行在 Workder node 中 Executor 上的工作单元
master 和 worker 通过 Akka 方式进行通信的
默认的存储级别 - MEMORY_ONLY
hive 的元数据存储在 derby 和 MySQL 中有什么区别 - 多会话
DataFrame 和 RDD 最大的区别 - 多了 schema
RDD机制
- 分布式弹性数据集,简单的理解成一种数据结构,是spark框架上的通用货币
- 所有算子都是基于rdd来执行的
- rdd执行过程中会形成dag图,然后形成lineage保证容错性等
- 从物理的角度来看rdd存储的是block和node之间的映射
ShuffleManager(shuffle管理器)
ShuffleManager随着Spark的发展有两种实现的方式,分别为HashShuffleManager和SortShuffleManager,因此spark的Shuffle有Hash Shuffle和Sort Shuffle两种
Spark中的HashShufle的有哪些不足?
- shuffle产生海量的小文件在磁盘上,此时会产生大量耗时的、低效的IO操作;
- 容易导致内存不够用,由于内存需要保存海量的文件操作句柄和临时缓存信息
- 容易出现数据倾斜,导致OOM
spark hashParitioner的弊端
- 分区原理:对于给定的key,计算其hashCode
- 弊端是数据不均匀,容易导致数据倾斜
map与flatMap的区别
- map:对RDD每个元素转换,文件中的每一行数据返回一个数组对象
- flatMap:对RDD每个元素转换,然后再扁平化,将所有的对象合并为一个对象,会抛弃值为null的值
union操作是产生宽依赖还是窄依赖?
- 窄依赖
常用的action
collect,reduce,take,count,saveAsTextFile等
rdd有几种操作类型
三种:
1、transformation,rdd由一种转为另一种rdd
2、action
3、cronroller,控制算子(cache/persist) 对性能和效率的有很好的支持
什么场景下要进行persist操作?
以下场景会使用persist
- 某个步骤计算非常耗时或计算链条非常长,需要进行persist持久化
- shuffle之后为什么要persist,shuffle要进性网络传输,风险很大,数据丢失重来,恢复代价很大
- shuffle之前进行persist,框架默认将数据持久化到磁盘,这个是框架自动做的。
Spark容错机制-血统(Lineage)容错
一般来说,分布式数据集的容错性有两种方式:数据检查点和记录数据的更新。
Lineage本质上很类似于数据库中的重做日志(Redo Log),只不过这个重做日志粒度很大,是对全局数据做同样的重做进而恢复数据。
相比其他系统的细颗粒度的内存数据更新级别的备份或者LOG机制,RDD的Lineage记录的是粗颗粒度的特定数据Transformation操作(如filter、map、join等)行为。
groupBy和groupByKey
比如(A,1),(A,2);使用groupBy之后结果是(A,((A,1),(A,2)));
使用groupByKey之后结果是:(A,(1,2));关键区别就是合并之后是否会自动去掉key信息;
Storm
Flink
常见问题
spark快的原因
- Spark基于内存,尽可能的减少了中间结果写入磁盘和不必要的sort、shuffleSpark
- 对于反复用到的数据进行了缓存
- Spark对于DAG进行了高度的优化,具体在于Spark划分了不同的stage和使用了延迟计算技术
Spark为什么比mapreduce快
1、内存(性能高)、磁盘(可靠)
2、DAG有向无环图在此过程中减少了shuffle以及落地磁盘的次数(一般而言)
Spark 支持将需要反复用到的数据给 Cache 到内存中,减少数据加载耗时,所以 Spark 跑机器学习算法比较在行(需要对数据进行反复迭代)
Spark的DAG实质上就是把计算和计算之间的编排变得更为细致紧密,使得很多MR任务中需要落盘的非Shuffle操作得以在内存中直接参与后续的运算,并且由于算子粒度和算子之间的逻辑关系使得其易于由框架自动地优化
3、Spark是粗粒度资源调度(多线程模型),MapReduce是细粒度资源调度(多进程模型)
粗粒度资源调度的优点是执行速度快,缺点是不能使集群得到充分的利用;反之亦然。
Mapreduce操作的mapper和reducer阶段相当于spark中的哪几个算子
相当于spark中的map算子和reduceByKey算子,区别:MR会自动进行排序的,spark要看具体partitioner
Reference
https://www.jianshu.com/p/7a8fca3838a4
19.3 - HDFS-01基本介绍
简介
介绍
Hadoop Distributed File System(简称 HDFS)是一个分布式文件系统。HDFS 有着高容错性(fault-tolerent)的特点,并且设计用来部署在低廉的(low-cost)硬件上。而且它提供高吞吐量(high throughput)来访问应用程序的数据,适合那些有着超大数据集(large data set)的应用程序。HDFS 放宽了(relax)POSIX 的要求(requirements)这样可以实现流的形式访问(streaming access)文件系统中的数据。HDFS 开始是为开源的 apache 项目 nutch 的基础结构而创建,HDFS 是 hadoop 项目的一部分,而 hadoop 又是 lucene 的一部分。
发展历史
Lucene其实是一个提供全文文本搜索的函数库,它不是一个应用软件。它提供很多API函数,是一个开放源代码的全文检索引擎工具包,让你可以运用到各种实际应用程序中。它提供了完整的查询引擎和索引引擎以及部分的文本分析功能。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。
Nutch是一个建立在Lucene核心之上的Web搜索的实现,它是一个真正的应用程序。可以直接下载使用。它在Lucene的基础上加了网络爬虫和一些和Web相关的内容。其目的就是想从一个简单的站内索引和搜索推广到全球网络的搜索上,就像Google和Yahoo一样。Nutch 中还包含了一个分布式文件系统用于存储数据。从 Nutch 0.8.0 版本之后,Doug Cutting 把 Nutch 中的分布式文件系统以及实现 MapReduce 算法的代码独立出来形成了一个新的开源项 Hadoop。Nutch 也演化为基于 Lucene 全文检索以及 Hadoop 分布式计算平台的一个开源搜索引擎。
应用场景
名词解释
Hadoop:一个分布式系统基础架构,由Apache基金会开发。用户可以在不了解分布式底层细节的情况下,开发分布式程序。充分利用集群的威力高速运算和存储。
Distributed:分布式计算是利用互联网上的计算机的 CPU 的共同处理能力来解决大型计算问题的一种计算科学。
File system:文件系统是操作系统用于明确磁盘或分区上的文件的方法和数据结构;即在磁盘上组织文件的方法。也指用于存储文件的磁盘或分区,或文件系统种类。
架构

架构主要由四个部分组成,分别为HDFS Client、NameNode、DataNode和Secondary NameNode。下面分别介绍这四个组成部分。
HDFS Client
和 HDFS 打交道是通过客户端,无论读取一个文件或者写一个文件,都是把数据交给 HDFS client,它负责和 Name nodes 以及 Data nodes 联系并传输数据。
主要功能如下:
- 文件切分。文件上传 HDFS 时,将文件切分成一个一个的 Block,然后进行存储;
- 与 NameNode 交互,获取文件的位置信息;
- 与 DataNode 交互,读取或者写入数据;
- Client 提供一些命令来管理 HDFS,比如启动或者关闭HDFS;
- Client 可以通过一些命令来访问 HDFS;
NameNode
也就是Master,它是一个管理者。
主要功能如下:
- 管理 HDFS 的名称空间;
- 处理客户端读写请求
- 管理 DataNode 回报的数据块(Block)映射信息;
- 配置副本策略。
DataNode
可以理解为 Slave。DataNode 是 Block 真正存储的地方。DataNode的本地磁盘以文件形式存储着Block信息。同时还存储着Block的元数据信息文件。元数据主要存储MD5值用来进行验证
HDFS在启动时,DataNode 会向 NameNode汇报 block的信息。
DataNode通过向NameNode发送心跳保持与其联系(3秒一次),如果 NameNode 10 分钟没有收到 DataNode 的心跳,则认为其已经 lost,并复制其上的 block 到其它 DataNode。
主要功能如下:
- 存储实际的数据块;
- 执行数据块的读/写操作。
SecondaryNameNode
并非NameNode的热备。当 NameNode 挂掉时,并不能马上替换NameNode并提供服务。备用NameNode 通常在与 主NameNode 不同的计算机上运行,它的内存要求与 主NameNode 的相同。
主要功能如下:
- 辅助NameNode,分担其工作量;
启动备用 NameNode 时,会从映像文件 fsimage 中读取 HDFS 状态,然后启用“编辑日志文件”对它进行编辑。然后将新的HDFS状态写入fsimage,并使用“空编辑文件”启动正常操作。
- 定期合并 Fsimage 和 Edits,并推送给 NameNode;
由于 NameNode 只在启动时合并 fsimage 和编辑文件,随着时间推移,“编辑日志文件”会变得非常大。导致在下次重新启动 NameNode 时需要花费更长的时间。备用NameNode 定期合并 fsimage 和“编辑日志文件”,并将“编辑日志文件”的大小保持在限定范围内。减少 NameNode 启动时间
- 紧急情况下,可辅助恢复NameNode。
物理拓扑
至少分为三层:
- 顶层交换机
- 机架
- 服务器 hdfs具备机架感知,可以感知集群的物理拓扑,因此在数据副本放置的时候可以根据物理拓扑进行分配,同时能够为用户就近选择读写的datanode节点。
数据分布
副本分布
hdfs拓扑模块具备机架感知功能,这个功能好处在于能够让client从最近的存储节点读数据,也能够在数据副本复制的时候按照从近到远的方式复制,提升总体带宽,同时也能够使副本放置尽可能合理,从而提高数据可靠性。 数据副本放置策略对于数据的可靠性、读写性能、可用性影响较大。
hdfs数据副本在client请求新的Block时由NameNode确定其存放在哪些DataNode节点,hdfs默认的副本分配方式是将第一个副本放置在离client最近的DataNode节点,其他两个副本放在不同的机架上。在充分保证读写性能的同时尽可能的保证最大的可靠性和可用性。 hdfs的副本放置可以总结为两点:
- 一个DataNode上不会出现一个Block的两个副本;
- 一个机架上不会存储一个Block的三个及以上的副本(前提:机架数量充足)。
副本管理
hdfs的副本管理粒度是以Block为单位的,Block大小为128MB(hadoop 1.x都是64MB,hadoop 2.x都是128MB),如果副本数量为3,那么一个Block就至少需要BlockID + 3*DataNodeID这样大小的元数据,这与当前很多流行的分布式系统设计是不一样的,比如tikv,其副本管理单位是一个raft group,这个raft group管理多个block,通常一个raft group管理的数据量大小在数十G规模。这也是目前很多分布式系统常用的副本管理方式。 hdfs的Block是动态创建的,client向NameNode申请新的block,NameNode会分配一个新的BlockID并为这个Block分配三个DataNode节点,用作副本存放。
数据一致性
hdfs只支持一写多读模式,这种模式简化了数据一致性的设计,因为不需要在client之间同步写入状态了,cephfs支持多写多读,其多个client之间的状态同步比较复杂。 另外hdfs的文件只支持追加写入,这同样有利于数据一致性的设计实现,当然这种只支持追加写的模式也是与其应用场景相结合的。同时仅支持追加写对于带宽也是友好的。
数据复制
hdfs的数据复制以pipeline方式进行,数据从client发到与其最近的DataNode节点,然后由第一个DataNode节点复制给第二个DataNode节点,这样以此类推,每个package的ack按照复制方向的反方向流动,最终返回给client。
写数据一致性
hdfs保证同一时间只有一个client可以写文件,同时可见性只是在文件close和用户显示调用flush的时候。如果只是正常的写入返回并不保证写入的数据对用户可见,这个与文件创建时其配置有一定关系,具体可参考what's the HDFS writing consistency。
读数据一致性
对于同一个client,hdfs保证“read your write”一致性语义,实现方式主要是通过记录client的写状态ID,在执行读请求时会携带这个ID,这个ID会发给NamaNode,由NameNode保证在允许其读请求执行之前其写请求已经被执行。 对于多个client,hdfs提供msync调用,在读取非自身写的时候,先执行msync,msync会刷新NameNode上其自身的状态ID,使其ID保持最新状态,能够读到其他client写入的最新数据。
工作流程
写数据过程

- 客户端通过 Distributed FileSystem 模块向 NameNode 请求上传文件,NameNode 检查目标文件是否已存在,父目录是否存在。
- NameNode 返回是否可以上传。
- 客户端请求第一个 block 上传到哪几个 datanode 服务器上。
- NameNode 返回 3 个 datanode 节点,分别为 dn1、dn2、dn3。
- 客户端通过 FSDataOutputStream 模块请求 dn1 上传数据,dn1 收到请求会继续调用 dn2,然后 dn2 调用 dn3,通信管道建立完成。
- dn1、dn2、dn3 逐级应答客户端。
- 客户端开始往 dn1 上传第一个block(先从磁盘读取数据放到一个本地内存缓存),以 packet 为单位,dn1 收到一个 packet 就会传给 dn2,dn2 传给 dn3;dn1 每传一个packet 会放入一个应答队列等待应答。
- 当一个block传输完成之后,客户端再次请求NameNode上传第二个block的服务器。(重复执行3-7步)。
读数据过程

- 客户端通过 Distributed FileSystem 向 NameNode 请求下载文件,NameNode 通过查询元数据,找到文件块所在的DataNode地址。
- 挑选一台 DataNode(就近原则,然后随机)服务器,请求读取数据。
- DataNode 开始传输数据给客户端(从磁盘里面读取数据输入流,以 packet 为单位来做校验)。
- 客户端以 packet 为单位接收,先在本地缓存,然后写入目标文件。
Second NameNode工作机制

第一阶段:NameNode启动
第一次启动NameNode格式化后,创建fsimage和edits文件。如果不是第一次启动,直接加载编辑日志和镜像文件到内存。
客户端对元数据进行增删改的请求。
NameNode记录操作日志,更新滚动日志。
NameNode在内存中对数据进行增删改查。 第二阶段:Secondary NameNode工作
Secondary NameNode询问NameNode是否需要checkpoint。直接带回NameNode是否检查结果。
Secondary NameNode请求执行checkpoint。
NameNode滚动正在写的edits日志。
将滚动前的编辑日志和镜像文件拷贝到Secondary NameNode。
Secondary NameNode加载编辑日志和镜像文件到内存,并合并。
生成新的镜像文件fsimage.chkpoint。
拷贝fsimage.chkpoint到NameNode。
NameNode将fsimage.chkpoint重新命名成fsimage。
19.4 - HDFS-02基本命令
hdfs dfs与hadoop fs
HDFS的命令实际上和Linux命令相似,基本就是在 hdfs dfs - 加上Linux命令即可( hdfs dfs -ls / 和 hadoop fs -ls / 效果一样)
hadoop命令
打印配置路径
| |
HDFS命令
查看帮助
| |
查看当前目录信息
| |
上传文件
| |
剪切文件
| |
合并下载
| |
创建文件夹
| |
移动文件
| |
复制文件
| |
删除文件
| |
查看文件
| |
查看文件数量
| |
查看空间
| |
19.5 - Spark
Introduction
Spark intro
19.5.1 - 01.Spark基本介绍
概述
Spark 是 UC Berkeley AMP Lab 开源的通用分布式并行计算框架,目前已成为 Apache 软件基金会的顶级开源项目。Spark 支持多种编程语言,包括 Java、Python、R 和 Scala,同时 Spark 也支持 Hadoop 的底层存储系统 HDFS,但 Spark 不依赖 Hadoop。
Spark优势
高效性 Spark 比 MapReduce 快100倍。
- Spark基于内存存储中间计算结果,减少了中间结果写入磁盘的IO和不必要的sort、shuffle
- Spark对于反复用到的数据进行了缓存
- Spark通过并行计算DAG图的优化,具体在于Spark划分了不同的stage,减少了不同任务之间的依赖,使用延迟计算技术降低了延迟等待时间
易用性 不同于MapReduce仅支持Map和Reduce两种编程算子,Spark提供了超过80种不同的Transformation和Action算子,如map、reduce、filter、groupByKey、sortByKey、foreach等,并且采用函数式编程风格,实现相同的功能需要的代码量极大缩小。
通用性 Spark提供了统一的解决方案。Spark可以用于批处理、交互式查询(Spark SQL)、实时流处理(Spark Streaming)、机器学习(Spark MLlib)和图计算(GraphX)。这些不同类型的处理都可以在同一个应用中无缝使用。
兼容性 Spark能够跟很多开源工程兼容使用。如Spark可以使用Hadoop的YARN和Apache Mesos作为它的资源管理和调度器,并且Spark可以读取多种数据源,如HDFS、HBase、MySQL等。
Spark基本概念
Spark是一种基于内存的快速、通用、可扩展的大数据分析计算引擎
RDD:是弹性分布式数据集(Resilient Distributed Dataset)的简称,是分布式内存的一个抽象概念,提供了一种高度受限的共享内存模型。
DAG:是Directed Acyclic Graph(有向无环图)的简称,反映RDD之间的依赖关系。
Driver Program:控制程序,负责为Application构建DAG图。
Cluster Manager:集群资源管理中心,负责分配计算资源。
Worker Node:工作节点,负责完成具体计算。
Executor:是运行在工作节点(Worker Node)上的一个进程,负责运行Task,并为应用程序存储数据。
Application:用户编写的Spark应用程序,一个Application包含多个Job。
Job:作业,一个Job包含多个RDD及作用于相应RDD上的各种操作。
Stage:阶段,是作业的基本调度单位,一个作业会分为多组任务,每组任务被称为“阶段”。
Task:任务,运行在Executor上的工作单元,是Executor中的一个线程。
Application由多个Job组成,Job由多个Stage组成,Stage由多个Task组成。Stage是作业调度的基本单位。
RDD
RDD(Resilient Distributed Dataset)是Spark中最基础的数据结构之一,它是一个可分区、可并行操作、容错的数据集合,可以跨集群进行分布式计算。RDD的每个分区存储在不同的节点上,且每个分区都可以被处理器并行计算,以实现分布式计算的目的。
DataFrame
DataFrame是Spark SQL中的一种数据结构,是一个带有命名列的分布式数据集合。与RDD不同,DataFrame具有模式(Schema)信息,可以用于结构化数据的处理和分析。DataFrame也支持类似SQL的查询操作,可以通过Spark SQL或DataFrame API进行操作。
DataSet
Dataset是Spark 1.6版本中引入的一种数据结构,是DataFrame的强类型版本。与DataFrame不同,Dataset具有编译时类型检查和类型安全性,并且可以通过Scala和Java中的Lambda表达式进行操作。Dataset既可以像DataFrame一样进行结构化数据处理,也可以像RDD一样进行函数式编程。
Spark核心模块

SparkCore
SparkCore中提供了Spark最基础与最核心的功能, Spark其他的功能如: SparkSQL, SparkStreaming, GraphX, MLlib# 8276SparkCore的基础上进行扩展的
SparkSQL
SparkSQLJSparkFICHE(EzeFa(CSRAMAAiitSparkSQL, 用户可以使用SQL或者ApacheHive版本的SQL方言(HQL) 来查询数据。
SparkStreaming
SparkStreaming是Spark平台上针对实时数据进行流式计算的组件, 提供了丰富的处理
数据流的API。
SparkMLlib
MLlib是Spark中用于机器学习的组件,它包括了许多常见的机器学习算法和工具,如分类、回归、聚类、降维等。MLlib的算法可以处理大规模的数据集合,并且可以通过RDD、DataFrame和Dataset进行操作。
Spark架构设计
整体架构
Spark集群由以下部分组成:
- Driver
- Cluster Manager(Standalone,Yarn 或 Mesos)
- Worker Node 对于每个Spark应用程序,Worker Node上存在一个Executor进程,Executor进程中包括多个Task线程。

Spark核心流程
启动流程
启动Spark时,Spark将经历以下步骤:
- Spark应用程序的入口点是Spark的驱动程序。驱动程序是一个包含main函数的JVM进程。当驱动程序运行时,它创建一个SparkContext对象。这个对象是与Spark集群通信的主要途径,它包含了所有集群的配置信息。
- SparkContext对象与集群管理器通信,并请求资源来执行Spark应用程序。集群管理器可以是Standalone、YARN或Mesos。当SparkContext向集群管理器发出请求时,它将分配给应用程序一个或多个执行器进程。
- 执行器进程启动后,它们将向SparkContext注册,并等待接收任务。当Spark应用程序运行时,SparkContext将根据需要将任务发送给执行器进程。
- 在运行期间,Spark应用程序将创建RDD(Resilient Distributed Datasets)并对它们执行操作。RDD是Spark的核心抽象,它是一个分布式的、可缓存的、不可变的数据集合。应用程序可以从文件、Hive表、数据库或内存数据结构等数据源创建RDD,然后对它们执行各种转换操作(如map、filter、reduceByKey等)以生成新的RDD。
- 应用程序也可以将RDD存储到磁盘上,以便在以后的运行中重复使用。Spark支持不同类型的存储系统,包括HDFS、S3、Cassandra、HBase等。
- 当应用程序完成时,SparkContext将释放资源并关闭执行器进程。应用程序也可以手动停止SparkContext来终止应用程序。
执行流程
Apache Spark是一个用于分布式数据处理的计算引擎,其执行流程主要包括以下步骤:
- 应用程序创建SparkContext对象:应用程序通过创建SparkContext对象来连接到Spark集群。SparkContext对象充当与集群交互的主要接口。
- 读取数据并创建RDD:Spark将数据读入内存中,并将其表示为弹性分布式数据集(RDD)。RDD是Spark中的基本数据结构,它允许数据在集群中的多个节点之间进行分布式处理。
- 转换RDD:应用程序可以对RDD进行转换,以执行各种操作。转换操作是惰性的,这意味着它们只有在执行操作时才会被计算。
- 缓存RDD:Spark支持将RDD缓存在内存中,以便反复使用。缓存可以提高性能,因为它可以减少I/O和计算开销。
- 执行操作:当应用程序调用操作时,Spark将转换操作应用于RDD,并在集群中的多个节点上执行计算。Spark使用任务来执行计算,并将任务分发给集群中的多个节点。
- 保存结果:一旦计算完成,应用程序可以将结果保存到磁盘或将其返回给驱动程序。 总之,Spark的执行流程涉及将数据加载到内存中,并使用RDD进行转换和操作,最终将结果保存到磁盘或返回给驱动程序。Spark执行计算时利用集群中多个节点的并行计算能力,从而实现高性能和高可伸缩性。
Shuffle
在Spark中,Shuffle是指将RDD(Resilient Distributed Dataset)中的数据重新分区,以便在不同节点上进行数据聚合或计算的过程。Shuffle操作是一种非常耗时和开销大的操作,因为它需要将数据从不同节点的Task中读取、序列化、写入磁盘、再从磁盘读取、反序列化等过程。
Spark中的Shuffle可以分为两种类型:
基于Hash的Shuffle:使用Hash函数将Key相同的记录映射到同一个分区中。由于Hash函数的随机性,Hash Shuffle通常可以比较好地实现数据的均衡分布。
基于Sort的Shuffle:使用Key值进行排序,将Key相同的记录分配到同一个分区中。由于Sort Shuffle需要先进行排序,因此其开销一般会比Hash Shuffle更高。 Spark Shuffle操作可以发生在多个阶段,包括Map端Shuffle和Reduce端Shuffle:
Map端Shuffle:在Map阶段,Shuffle操作是通过将Mapper任务的输出数据分区、排序、写入磁盘,再由Reduce任务读取磁盘上的分区文件进行Reduce操作。
Reduce端Shuffle:在Reduce阶段,Shuffle操作是通过将Mapper任务的输出数据写入内存中的缓冲区,当缓冲区达到一定的大小时,将缓冲区中的数据分区、排序、写入磁盘,最后由Reduce任务读取磁盘上的分区文件进行Reduce操作。 为了减少Shuffle操作的开销,Spark提供了一些优化方法,包括:
使用CombineByKey或reduceByKey等算子,在Map端进行局部聚合,减少Reduce端Shuffle的数据量。
使用Spark SQL的DataFrame或Dataset API,尽量使用Spark的内置优化,减少Shuffle的开销。
调整Spark SQL的shuffle分区数,适当增大shuffle分区数可以减少数据倾斜的问题。
将需要缓存的RDD或DataFrame先进行缓存,避免多次计算时导致Shuffle的开销。 综上所述,Shuffle是Spark中一个重要的操作,其开销很大,需要谨慎使用。针对具体的应用场景和需求,可以采用不同的优化策略来减少Shuffle的开销。
数据倾斜
Spark数据倾斜是指在数据分布不平衡的情况下,某些节点的数据处理时间远远长于其他节点,导致整个Spark应用程序的执行时间变慢。解决Spark数据倾斜的常见方法包括以下几个方面:
- 了解数据分布情况:首先需要了解数据分布的情况,通过查看数据的Key分布情况、数据大小、数据来源等信息,以及对任务进行Profile和监控,可以快速定位数据倾斜的问题。
- 重新分区:对于数据倾斜的情况,可以考虑重新分区,使得数据均匀分布在多个分区中,可以使用repartition或coalesce函数重新分区。
- 增加shuffle操作的并行度:对于包含shuffle操作的任务,可以增加其并行度,使得数据可以更均匀地分布到不同的节点上。可以通过调整spark.sql.shuffle.partitions参数来控制shuffle操作的并行度。
- 使用随机前缀:对于Key值分布不均的情况,可以使用随机前缀的方式来将数据打散到不同的分区中。通过增加随机前缀可以使得原本的Key值发生变化,从而均匀分布在不同的分区中。
- 增加缓存:对于一些常用的RDD或DataFrame,可以将其缓存起来,避免多次计算导致数据倾斜。缓存操作可以使用persist或cache函数。
- 使用广播变量:对于一些较小的数据集,可以使用广播变量将其广播到所有节点上,避免在每个节点上都重新计算一遍。广播变量可以使用broadcast函数。
- 使用动态调整算子:对于部分RDD数据倾斜的情况,可以使用动态调整算子,将数据分散到多个节点上进行处理。可以使用zipWithIndex函数实现动态调整算子。 综上所述,Spark数据倾斜的解决方法主要包括重新分区、增加shuffle操作的并行度、使用随机前缀、增加缓存、使用广播变量、使用动态调整算子等。针对具体问题,需要根据具体情况采用不同的解决方法。
RDD
Spark将数据保存分布式内存中,对分布式内存的抽象理解,提供了一个高度受限的内存模型,RDD在逻辑上集中,物理上存储在集群的多台机器上。
特点
RDD的特点包括:
- 弹性:RDD具有容错性,如果某个节点宕机,Spark可以根据RDD的依赖关系重新计算该节点上的数据,从而保证了程序的健壮性。
- 分区:RDD将数据集合按照一定规则进行分区,以便并行处理,不同的分区可以被不同的计算节点处理。
- 延迟计算:RDD采用延迟计算的方式,只有在需要计算结果时才会进行真正的计算,这可以有效地减少不必要的计算开销。
- 缓存:Spark可以将RDD缓存在内存中,避免重复计算,提高计算效率。
- 支持多种数据源:RDD支持多种数据源,包括本地文件系统、Hadoop文件系统、HBase、Cassandra等。 在Spark中,RDD是一种不可变的数据结构,意味着一旦创建就不能被修改,只能通过转换操作生成新的RDD。RDD的操作可以分为两类:转换操作和行动操作。转换操作是指对RDD进行某些计算,生成新的RDD,但并不会立即执行,只有在行动操作被调用时才会执行计算。行动操作是指触发RDD计算并返回结果的操作,如count、collect、reduce等。
总之,RDD是Spark中非常重要的一种数据结构,它可以实现分布式的数据处理,并具有容错性、弹性、延迟计算等特点。熟练掌握RDD的使用可以帮助我们更好地进行分布式计算。
创建方式
RDD可以由两种方式创建:
- 从已有数据集合(如Hadoop文件、本地文件、数据库等)创建,可以通过SparkContext的textFile等函数创建。如:使用程序中的集合创建、使用本地文件系统创建、使用hdfs创建、基于数据库db创建、基于Nosql创建(如hbase)、基于s3创建、基于数据流(如socket创建)
- 通过转换已有的RDD,生成新的RDD,可以通过Map、Filter、GroupBy等函数进行操作。
特征
由一系列parition组成 RDD来说,每个分片都会被一个计算任务处理,并决定并行计算的粒度。用户可以在创建RDD时指定RDD的分片个数,如果没有指定,那么就会采用默认值。默认值就是程序所分配到的CPU Core的数目。
算子(函数)是作用在partition上 Spark中RDD的计算是以分片为单位的,每个RDD都会实现compute函数以达到这个目的。compute函数会对迭代器进行复合,不需要保存每次计算的结果。
RDD之间有依赖关系 RDD的每次转换都会生成一个新的RDD,所以RDD之间就会形成类似于流水线一样的前后依赖关系。当部分分区数据丢失时,Spark可以通过这个依赖关系重新计算丢失的分区数据,而不是对RDD的所有分区进行重新计算。
分区器作用在KV格式的RDD上 Spark中实现了两种类型的分片函数,一个是基于哈希的HashPartitioner,另外一个是基于范围的RangePartitioner。只有对于于key-value的RDD,才会有Partitioner,非key-value的RDD的Parititioner的值是None。Partitioner函数不但决定了RDD本身的分片数量,也决定了parent RDD Shuffle输出时的分片数量。
Partition提供数据最佳的计算位置,有利于数据处理的本地化。 对于一个HDFS文件,列表保存的是每个Partition所在块的位置。按照“移动数据不如移动计算”的理念,Spark在任务调度时,会尽可能将计算任务分配到其所要处理数据块的存储位置。
窄依赖和宽依赖
窄依赖(一对一):没有数据的shuffling,所有的父RDD的partition会一一映射到子RDD的partition中
宽依赖(一对多):发生数据的shuffling,父RDD中的partition会根据key的不同进行切分,划分到子RDD中对应的partition中
Spark部署模式
Spark作为一个数据处理框架和计算引擎,被设计在所有常见的集群环境中运行,在国内工作中主流的环境为Yarn,不过逐渐容器式环境也慢慢流行起来。
Local模式
想啥呢, 你之前一直在使用的模式可不是Local模式哟。所谓的Local模式, 就是不需要其他任何节点资源就可以在本地执行麓F代码的坏境,一般用于教学, 调试, 演示等, 之前在IDEA中运行代码的环境我们称之为开发环境, 不太一样。
解压缩文件
将spark-3.0.0-bin-hadoop3.2.tgz文件上传到Linux并解压缩, 放置在指定位置。
| |
启动Local环境
进入解压缩后的路径,执行如下命令
| |
提交应用
| |
Standalone模式
local本地模式毕竟只是用来进行练习演示的, 真实工作中还是要将应用提交到对应的集群中去执行, 这里我们来看看只使用Spark自身节点运行的集群模式, 也就是我们所谓的独立部署(Standalone) 模式。Spark的Standalone模式体现了经典的mastel-slave模式。
集群规划:
| Linux1 | Linux2 | Linux3 | |
|---|---|---|---|
| Spark | Worker Master | Worker | Worker |
解压缩文件
将spark-3.0.0-bin-hadoop3.2.tgz文件上传到Linux并解压缩, 放置在指定位置。
| |
修改配置文件
1)进入解压缩路径的conf目录,修改salves.tempalte文件名为slaves
| |
2)修改slaves文件,添加work节点
| |
3)修改spark-env.sh.template文件名为spark-env.sh
| |
4)修改spark-env.sh文件,添加JAVA_HOME环境变量和集群对应的master节点
| |
注意:7077端口,相当于hadoop3内部通信的8020端口,此处的端口需要确认自己的Hadoop配置 5)分发spark-standalone目录
| |
启动集群
1)执行命令
| |
2)查看三台服务器运行进行
| |
3)查看Master资源监控Web UI界面:http://linux1:8080
提交应用
| |
Yarn模式
独立部署(Standalone) 模式由Spark自身提供计算资源, 无需其他框架提供资源。这种方式降低了和其他第三方资源框架的耦合性, 独立性非常强。但是你也要记住, Spark主要是计算框架, 而不是资源调度框架. 以本身提供的资源调度并不是它的强项,所以还是和其他专业的资源调度框架集成会更靠谱一些。所以接下来我们来学习在强大的Yarn坏境下Spark是如何工作的(其实是因为在国内工作中, Yarn使用的非常多) 。
解压缩文件
将spark-3.0.0-bin-hadoop3.2.tgz文件上传到Linux并解压缩, 放置在指定位置。
| |
修改配置文件
1)修改hadoop配置文件/opt/module/hadoop/etc/hadoop/yarn-site.xml,并分发
| |
2)修改conf/spark-env.sh,添加JAVA_HOME和YARN_CONF_DIR配置
| |
启动HDFS和YARN集群
略
提交应用
| |
20 - 算法与数据结构
Introduction
算法与数据结构
20.1 - 数据结构
Introduction
数据结构
20.1.1 - LSM树
简介
背景
NoSQL数据库中,RocksDB、LevelDB、HBase以及Prometheus等,其底层的存储引擎都是基于LSM树,这一数据结构对了解存储引擎至关重要。
LSM树的核心特点是利用顺序写来提高写性能,但因为分层(此处分层是指的分为内存和文件两部分)的设计会稍微降低读性能,但是通过牺牲小部分读性能换来高性能写,使得LSM树成为非常流行的存储结构。
定义
Log Structured Merge Trees(LSM) 日志结构合并树:
- LSM树是一个横跨内存和磁盘的,包含多颗"子树"的一个森林。
- LSM树分为Level 0,Level 1,Level 2 ... Level n 多颗子树,其中只有Level 0在内存中,其余Level 1-n在磁盘中。
- 内存中的Level 0子树一般采用排序树(红黑树/AVL树)、跳表或者TreeMap等这类有序的数据结构,方便后续顺序写磁盘。
- 磁盘中的Level 1-n子树,本质是数据排好序后顺序写到磁盘上的文件,只是叫做树而已。
- 每一层的子树都有一个阈值大小,达到阈值后会进行合并,合并结果写入下一层。
- 只有内存中数据允许原地更新,磁盘上数据的变更只允许追加写,不做原地更新。

(图1 LSM树的组成与定义)
- 图1中分成了左侧绿色的内存部分和右侧蓝色的磁盘部分(定义1)。
- 图1左侧绿色的内存部分只包含Level 0树,右侧蓝色的磁盘部分则包含Level 1-n等多棵"树"(定义2)
- 图1左侧绿色的内存部分中Level 0是一颗二叉排序树(定义3)。注意这里的有序性,该性质决定了LSM树优异的读写性能。
- 图1右侧蓝色的磁盘部分所包含的Level 1到Level n多颗树,虽然叫做“树”,但本质是按数据key排好序后,顺序写在磁盘上的一个个文件(定义4) ,注意这里再次出现了有序性。
- 内存中的Level 0树在达到阈值后,会在内存中遍历排好序的Level 0树并顺序写入磁盘的Level 1。同样的,在磁盘中的Level n(n>0)达到阈值时,则会将Level n层的多个文件进行归并,写入Level n+1层。(定义5)
- 除了内存中的Level 0层做原地更新外,对已写入磁盘上的数据,都采用Append形式的磁盘顺序写,即更新和删除操作并不去修改老数据,只是简单的追加新数据。图1中右侧蓝色的磁盘部分,Level 1和Level 2均包含key为2的数据,同时图1左侧绿色内存中的Level 0树也包含key为2的数据节点。(定义6) 内存缓存(memtable)会通过写WAL的方式备份到磁盘,用来恢复数据,防止数据丢失。
论文:An Efficient Design and Implementation of LSM-Tree based Key-Value Store on Open-Channel SSD
组成

LSM树有以下三个重要组成部分:
- MemTable
MemTable是在内存中的数据结构,用于保存最近更新的数据,会按照Key有序地组织这些数据,LSM树对于具体如何组织有序地组织数据并没有明确的数据结构定义,例如Hbase使跳跃表来保证内存中key的有序。
因为数据暂时保存在内存中,内存并不是可靠存储,如果断电会丢失数据,因此通常会通过WAL(Write-ahead logging,预写式日志)的方式来保证数据的可靠性。
这个内存中 MemTable 不能无限地往里写,一是内存的容量毕竟有限,另外,MemTable 太大了读写性能都会下降。所以,MemTable 有一个固定的上限大小,一般是 32M。MemTable 写满之后,就被转换成 Immutable MemTable,然后再创建一个空的 MemTable 继续写。这个 Immutable MemTable,也就是只读的 MemTable,它和 MemTable 的数据结构完全一样,唯一的区别就是不允许再写入了。
- Immutable MemTable
当 MemTable达到一定大小后,会转化成Immutable MemTable。Immutable MemTable是将转MemTable变为SSTable的一种中间状态。写操作由新的MemTable处理,在转存过程中不阻塞数据更新操作。
Immutable MemTable 也不能在内存中无限地占地方,会有一个后台线程,不停地把 Immutable MemTable 复制到磁盘文件中,然后释放内存空间。每个 Immutable MemTable 对应一个磁盘文件,MemTable 的数据结构跳表本身就是一个有序表,写入的文件也是一个按照 Key 排序的结构,这些文件就是 SSTable。把 MemTable 写入 SSTable 这个写操作,因为它是把整块内存写入到整个文件中,这同样是一个顺序写操作。
- SSTable(Sorted String Table)
有序键值对集合,是LSM树组在磁盘中的数据结构。为了加快SSTable的读取,可以通过建立key的索引以及布隆过滤器来加快key的查找。
SSTable 被分为很多层,越往上层,文件越少,越往底层,文件越多。每一层的容量都有一个固定的上限,一般来说,下一层的容量是上一层的 10 倍。当某一层写满了,就会触发后台线程往下一层合并,数据合并到下一层之后,本层的 SSTable 文件就可以删除掉了。合并的过程也是排序的过程,除了 Level 0(第 0 层,也就是 MemTable 直接 dump 出来的磁盘文件所在的那一层。)以外,每一层内的文件都是有序的,文件内的 KV 也是有序的,这样就比较便于查找了。
数据操作
插入操作
LSM树的插入较简单,直接往内存中的Level 0排序树按照顺序插入即可。并不关心该数据是否已经在内存或磁盘中存在。已经存在该数据的话,则场景转换成更新操作。
该操作复杂度为树高log(n),n是Level 0树的数据量,可见代价很低,能实现极高的写吞吐量。
删除操作
LSM树的删除操作并不是直接删除数据,而是通过一种叫“墓碑标记”的特殊数据来标识数据的删除。
删除操作分为三种情况:
- 待删除数据在内存中
- 待删除数据在磁盘中
- 该数据根本不存在
待删除数据在内存中
删除数据时不能简单地将Level 0树中的节点删除,而是应该采用墓碑标记将其覆盖
为什么不能直接删除而是要用墓碑标记覆盖呢?
待删除数据在磁盘中
不直接去修改磁盘上的数据(理都不理它),而是直接向内存中的Level 0树中插入墓碑标记即可。
数据不存在
这种情况等价于在内存的Level 0树中新增一条墓碑标记,场景转换为在内存中插入墓碑标记操作。
综合看待上述三种情况,发现不论数据有没有、在哪里,删除操作都是等价于向Level 0树中写入墓碑标记。该操作复杂度为树高log(n),代价很低。
修改操作
LSM树的修改操作和删除操作很像,也是分为三种情况:
- 待修改数据在内存中
- 待修改数据在磁盘中
- 该数据根本不存在
待修改数据在内存中
直接定位到内存中Level 0树上黄色的老的key的位置,将其覆盖即可。
待修改数据在磁盘中
LSM树并不会去磁盘中的Level 1树上原地更新老的key的数据,而是直接将新的修改的节点插入内存中的Level 0树中。
数据不存在
同上,直接向内存中的Level 0树插入新的数据即可。
综上三种情况可以看出,修改操作都是对内存中Level 0进行覆盖/新增操作。该操作复杂度为树高log(n),代价很低。
LSM树的增加、删除、修改(这三个都属于写操作)都是在内存中倒腾,完全没涉及到磁盘操作,所以速度飞快,写吞吐量高。
查询操作
LSM树的查询操作会按顺序查找Level 0、Level 1、Level 2 ... Level n 每一颗树,一旦匹配便返回目标数据,不再继续查询。该策略保证了查到的一定是目标key最新版本的数据。
查询场景分析:
- 待查询数据在内存中
- 待查询数据在磁盘中 综合上述两种情况,LSM树的查询操作相对来说代价比较高,需要从Level 0到Level n一直顺序查下去。极端情况是LSM树中不存在该数据,则需要把整个库从Level 0到Level n给扫了一遍,然后返回查无此人(当然可以通过 布隆过滤器 + 建立稀疏索引 来优化查询操作)。代价大于以B/B+树为基本数据结构的传统RDB存储引擎。
合并操作
合并操作(compaction)是LSM树的核心(毕竟LSM树全称是日志结构合并树)。
之所以在增、删、改、查这四个基本操作之外还需要合并操作:
- 因为内存不是无限大,Level 0树达到阈值时,需要将数据从内存刷到磁盘中,这是合并操作的第一个场景;
- 需要对磁盘上达到阈值的顺序文件进行归并,并将归并结果写入下一层,归并过程中会清理重复的数据和被删除的数据(墓碑标记)。 分别对上述两个场景进行分析:
内存数据写入磁盘
内存中Level 0树在达到阈值后,归并写入磁盘Level 1树的场景。
对内存中的Level 0树进行中序遍历,将数据顺序写入磁盘的Level 1层即可,可以看到因为Level 0树是已经排好序的,所以写入的Level 1中的新块(追加)也是有序的(有序性保证了查询和归并操作的高效)。此时磁盘的Level 1层有两个Block块(追加)。
磁盘多个块归并
磁盘中Level 1层达到阈值时,对其包含的两个Block块进行归并,并将归并结果写入Level 2层的过程。
如果数据同时存在于较老的Block和较新的Block中。而归并的过程是保留较新的数据。
综上可以看到,以上两个场景由于原始数据都是有序的,因此归并的过程只需要对数据集进行一次扫描即可,复杂度为O(n)。
Compact策略
Compact操作是十分关键的操作,否则SSTable数量会不断膨胀。在Compact策略上,主要介绍两种基本策略:size-tiered和leveled。
不过在介绍这两种策略之前,先介绍三个比较重要的概念,事实上不同的策略就是围绕这三个概念之间做出权衡和取舍。
1)读放大:读取数据时实际读取的数据量大于真正的数据量。例如在LSM树中需要先在MemTable查看当前key是否存在,不存在继续从SSTable中寻找。 2)写放大:写入数据时实际写入的数据量大于真正的数据量。例如在LSM树中写入时可能触发Compact操作,导致实际写入的数据量远大于该key的数据量。 3)空间放大:数据实际占用的磁盘空间比数据的真正大小更多。上面提到的冗余存储,对于一个key来说,只有最新的那条记录是有效的,而之前的记录都是可以被清理回收的。
size-tiered 策略
size-tiered策略保证每层SSTable的大小相近,同时限制每一层SSTable的数量。如上图,每层限制SSTable为N,当每层SSTable达到N后,则触发Compact操作合并这些SSTable,并将合并后的结果写入到下一层成为一个更大的sstable。
当层数达到一定数量时,最底层的单个SSTable的大小会变得非常大。并且size-tiered策略会导致空间放大比较严重。即使对于同一层的SSTable,每个key的记录是可能存在多份的,只有当该层的SSTable执行compact操作才会消除这些key的冗余记录。
leveled策略
leveled策略也是采用分层的思想,每一层限制总文件的大小。
但是跟size-tiered策略不同的是,leveled会将每一层切分成多个大小相近的SSTable。这些SSTable是这一层是全局有序的,意味着一个key在每一层至多只有1条记录,不存在冗余记录。之所以可以保证全局有序,是因为合并策略和size-tiered不同。
优缺点
可以看到LSM树将增、删、改这三种操作都转化为内存insert + 磁盘顺序写(当Level 0满的时候),通过这种方式得到了无与伦比的写吞吐量。
LSM树的查询能力则相对被弱化,相比于B+树的最多3~4次磁盘IO,LSM树则要从Level 0一路查询Level n,极端情况下等于做了全表扫描。(即便做了稀疏索引,也是lg(N0)+lg(N1)+...+lg(Nn)的复杂度,大于B+树的lg(N0+N1+...+Nn)的时间复杂度)。
LSM树只append追加不原地修改的特性引入了归并操作,归并操作涉及到大量的磁盘IO,比较消耗性能,需要合理设置触发该操作的参数。
综上可以给出LSM树的优缺点:
优:增、删、改操作飞快,写吞吐量极大。
缺:读操作性能相对被弱化;不擅长区间范围的读操作; 归并操作较耗费资源。
LSMTree的增、删、改、查四种基本操作的时间复杂度分析如下所示:
| 操作 | 平均代价 | 最坏情况代价 |
|---|---|---|
| 插入 | 1 | 1 |
| 删除 | 1 | 1 |
| 修改 | 1 | 1 |
| 查找 | lgN | lgN |
小结
LSM树的设计原则:
- 先内存再磁盘
- 内存原地更新
- 磁盘追加更新
- 归并保留新值
Reference
20.1.2 - 红黑树
简介
红黑树(Red-Black Tree),简称 R-B Tree。它是一种不严格的平衡二叉查找树。
性质
红黑树的性质(重点):
1、每个节点不是红色就是黑色
2、不可能有连在一起的红色节点
3、根节点都是黑色 root
4、每个红色节点节点的两个子节点都是黑色,叶子节点(NIL节点)都是黑色:出度为0满足了性质就可以近似的平衡了
5、从任意节点到其每个叶子的所有路径都包含相同数据的黑色节点
正式因为规则限制,才保证了红黑树的自平衡。红黑树从根到叶子的最长路径不会超过最短路径的2倍。
变换规则
为了满足红黑树的性质,有3种变换规则:所有插入的点默认为红色
1、改变颜色:当前节点的父节点是红色,且叔叔节点(祖父节点的另一个子节点)也是红色
(1)把父节点设为黑色
(2)把叔叔节点也设为黑色
(3)把爷爷节点(父节点的父节点)设为红色
(4)把指针定义到爷爷节点设为当前要操作的节点
2、左旋:当前父节点是红色、叔叔节点是黑色,且当前的节点是右子树。
(1)以父节点作为左旋
3、右旋:当前父节点是红色,叔叔节点时黑色,且当前的节点是左子树。
(1)把父节点变为黑色
(2)把爷爷节点变为红色
(3)以爷爷节点旋转
示例:插入6


应用
JDK的集合类TreeMap和TreeSet底层就是红黑树来实现的,在JDK8中,连HashMap也用到了红黑树。
20.1.3 - 跳表
简介
跳表(SkipList)是一个随机化的数据结构,可以被看做二叉树的一个变种,它在性能上和红黑树,AVL树不相上下,但是跳表的原理非常简单,目前在Redis和LeveIDB中都有用到。
定义
增加了向前指针的链表叫作跳表。跳表全称叫做跳跃表,简称跳表。跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。
跳表的由来
对于一个单链表来讲,即便链表中存储的数据是有序的,如果想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率很低,时间复杂度很高是 O(n)。
如果像下图中那样,对链表建立一级“索引”,查找起来会更快一些,每两个结点提取一个结点到上一级,把抽出来的那一级叫做索引或索引层。图中的 down 表示 down 指针,指向下一级结点。

从这个例子可以看出,加来一层索引之后,查找一个结点需要遍历的结点个数减少了,即查找效率提高了。
跟前面建立第一级索引的方式相似,在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。现在我们再来查找 16,只需要遍历 6 个结点了,需要遍历的结点数量又减少了。

这种链表加多级索引的结构,就是跳表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以跳查找的查找速度也就变快了。
复杂度
时间复杂度
按照上面讲的,每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是 n/2,第二级索引的结点个数大约就是 n/4,第三级索引的结点个数大约就是 n/8,依次类推,也就是说,第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)。
假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,可以得到 n/(2^h)=2,从而求得 h=log2(n)-1(log以2为底的n)。如果包含原始链表这一层,整个跳表的高度就是 log2(n)。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。
假设要查找的数据是 x,在第 k 级索引中,遍历到 y 结点之后,发现 x 大于 y,小于后面的结点 z,所以通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引。在第 k-1 级索引中,y 和 z 之间只有 3 个结点(包含 y 和 z),所以在 K-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个结点。
因此,m 等于 3。所以在跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。
空间复杂度
假设原始链表大小为 n,第一级索引大约有 n/2 个结点,第二级索引大约有 n/4 个结点,以此类推,每上升一级就减少一半,直到剩下 2 个结点。如果把每层索引的结点数写出来,就是一个等比数列。
这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)。
前面都是每两个结点抽一个结点到上级索引,如果每三个结点或五个结点,抽一个结点到上级索引,就不用那么多索引结点了。
可以看出,第一级索引需要大约 n/3 个结点,第二级索引需要大约 n/9 个结点。每往上一级,索引结点个数都除以 3。通过等比数列求和公式,总的索引结点大约就是 n/3+n/9+n/27+...+9+3+1=n/2。尽管空间复杂度还是 O(n),但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。
操作
跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 O(logn)。
插入
为了保证原始链表中数据的有序性,需要先找到要插入的位置,这个查找操作就会比较耗时。
对于跳表来说,查找某个结点的时间复杂度是 O(logn),所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是 O(logn)。
删除
如果要删除的结点在索引中也有出现,除了要删除原始链表中的结点,还要删除索引中的。因为单链表中的删除操作需要拿到要删除结点的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点的时候,一定要获取前驱结点。当然,如果用的是双向链表,就不需要考虑这个问题了。
跳表索引动态更新
当不停地往跳表中插入数据时,如果不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。

作为一种动态数据结构,需要某种手段来维护索引与原始链表大小之间的平衡,即如果链表中结点多了,索引结点也就相应增加,避免复杂度退化,以及查找、插入、删除操作性能下降。像红黑树、AVL 树这样平衡二叉树,它们是通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护前面提到的“平衡性”。
随机函数
通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那就将这个结点添加到第一级到第 K 级这 K 级索引中。

随机函数的选择很有讲究,从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。随机函数的选择就不展开了。
FAQ
为什么 Redis 要用跳表来实现有序集合,而不是红黑树?
Redis 中的有序集合是通过跳表来实现的,严格来讲还用到了散列表。Redis 的开发手册中有序集合支持的核心操作主要有下面这几个:
- 插入一个数据;
- 删除一个数据;
- 查找一个数据;
- 按照区间查找数据(比如查找值在[100, 200]之间的数据);
- 迭代输出有序序列。 其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。
Redis 用跳表来实现有序集合还有其他原因,比如,跳表更容易代码实现(虽然跳表的实现也不简单,但比起红黑树来说还是简单、可读性好,不容易出错)。另外跳表更加灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。可以直接拿来使用,不用自己去实现,但是跳表并没有一个现成的实现,需要自己实现。
20.2 - 算法
Introduction
算法
20.2.1 - 雪花算法
简介
雪花算法:由Twitter开源的分布式ID生成算法。主要应用于分库分表场景中的全局ID作为业务主键,或者生成全局唯一的订单号。
名称由来:一般的雪花大约由10的19次方哥水分子组成。在雪花的形成过程中,会形成不同的结构分支,大自然中并不存在两片完全一样的雪花,每一片雪花都会有其自己独特的形状。雪花算法的意思就是表示生成的ID如雪花一般独一无二。
解决唯一ID一般方法:
- UUID
- 系统时间戳
- Redis原子递增
- 全局表自增ID
分布式ID除了唯一性,还需要满足以下特征:
- 单调递增
保证下一个ID号一定大于上一个
- 保证安全(无规则性)
ID号需要无规则性,不能让别人根据ID号推测出信息和业务数据量,增加恶意用户爬取数据的难度
- 含时间戳
ID需要记录系统时间戳
- 高可用
获取分布式ID的请求,服务至少要保证99.999%的情况下给创建一个全局唯一的分布式ID
- 低延迟
获取分布式ID的请求要快、延迟低
- 高QPS
服务器要支撑并且成功创建10万个分布式ID
实现原理
0 - 00000000 00000000 000000000 00000000 0 - 00000000 00 - 00000000 0000
组成部分:
- 符号位 (1bit)
- 时间戳(41bit)
- 机器码(10bit)
- 序列号(12bit)
优缺点
优点
- 分布式系统内不会产生ID碰撞,效率高
- 不需要依赖数据库等第三方系统,稳定性更高,可以根据自身业务分配bit位,非常灵活
- 生成ID性能非常高,每秒能生成26万个自增可排序的ID
缺点
- 如果机器回拨,可能导致ID重复
- 分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况
Reference
21 - 前端
Introduction
前端基础知识,如三大框架
21.1 - Vue
Introduction
vue.js框架
21.1.1 - js封装函数及axios封装
一、js封装
方法一
1、被封装方法,文件httputil.js
| |
2、引用
| |
方法二
1、被封装方法,文件httputil.js
| |
2、引用
| |
二、axios封装
1、axiosutil.js
Expand/Collapse Code Block
| |
2、引用Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import fetch from '../util/axiosutil.js'
function xxx(){
fetch({
method: 'get',
url: url,
params:{
id: "123",
name: "zhangsan"
}
})
.then(res => {
console.log(res)
})
}
import {Notification as notify} from 'element-ui';
function xxx(){
fetch({
url: url,
}).then(response => {
let res = response.data;
console.log(res);
if (res.code == 200)
commit('setHeadPicData', res.data);
}).catch(e => {
notify.error({
title: '头图审核提交失败',
message: '原因:服务器连接失败',
})
});
}
21.1.2 - vue保存时上传图片
前端代码
Expand/Collapse Code Block
| |
后端代码
Expand/Collapse Code Block
| |
21.1.3 - vue错误集锦
1、vue中报ES6三点语法错误
1.1 错误描述

1.2 环境介绍
老项目.babelrc
| |
新项目.babelrc
| |
1.3 解决办法
1、修改新项目.babelrc
| |
2、检查babel版本
| |
3、依旧出现错误

4、安装vue-app包
!!!注意需要在package.json同目录下,安装完之后package.json中会增加相关包记录
| |
2、子组件访问不了父组件
2.1 问题描述
子组件访问父组件总是报错,父组件方法没有定义typeError: this.$parent.updateShopinfo is not a function
部分代码:
Expand/Collapse Code Block
| |
2.2 原因
“
3、Vue-resource请求传参问题
3.1 问题描述
使用post传参时,发现参数格式不正确,接口返回错误。
3.2 知识点
两种类型都可以通过NetWork的Headers查看,Form Data是如xx=123&yy=456的格式,Request Payload是如{xx:"123", yy:"456"}的格式
3.3 原因
参数传的格式是Form Data,而接口需要的是Request Payload格式。默认的Content-Type用的是application/x-www-form-urlencoded,即Form Data,默认的报文都是这种格式。但原生的ajax报文头用的是text/plain;charset=UTF-8,这种格式下大部分字符都是不编码
3.4 解决办法
1)在vue实例中添加headers字段
| |
2)直接设置参数
| |
3)请求时修改
| |
将emulateJSON改为false即为Request Payload格式。
3.5 附:axios请求
Expand/Collapse Code Block
| |
3.6 附 Post请求参数书写格式
| |
3.7 附参考代码
Expand/Collapse Code Block
| |
4、函数调用参数问题
若函数形参使用{},则传递参数时名称需要绝对保持一致
| |
5、回调函数使用问题
使用回调函数一般用{}包住参数
1、常规用法
| |
2、回调函数指定默认值
| |
6、解决element-ui从1.x升级到2.x报错问题
6.1 问题描述
| |
但是2.x版本的element-ui已经是使用import 'element-ui/lib/theme-chalk/index.css'导入了,代码中并没有'element-ui/lib/theme-default/index.css'。
6.2 解决办法
.babelrc文件内容
| |
将上述内容修改成如下即可:
| |
7、el-table-column组件不能显示提示列表
7.1 原因
该组件某些情况下需要设置value属性,否则即使有数据也不能显示。
7.2 解决办法
在返回数组中的每一个对象中添加value属性
| |
8、字体无法解析
8.1 问题描述
parse failed: Unexpected character ''
8.2 原因
字体文件无法解析
8.3 解决办法
在webpack.config.js中配置
| |
21.1.4 - vue结合后台接口配置调试环境
1、配置webpack.config.js文件中output和devServer
Expand/Collapse Code Block
| |
devServer中contentBase为调试时(npm run dev)访问的index.html的文件路径,port为端口。output中path为index.html引用的js文件路径,index.html代码如下:
| |
两者路径需要同步。
2、调试时前端指定后端访问ip/域名和端口,
在main.js中添加如下代码:
Expand/Collapse Code Block
| |
3、配置后端程序允许跨域请求
不同版本spring/springboot的默认配置可能支持跨域,能正常请求,则跳过此步骤。如果不能正常请求且Console中报错:
| |
此时Network的Response Headers中Access-Control-xxx只有 Access-Control-Allow-Origin: * ,则需要后端配置。参考链接为:SpringBoot 1.X、2.X 解决跨域问题 和 Spring boot 和Vue开发中CORS跨域问题。Expand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CustomCORSConfiguration {
private CorsConfiguration buildConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
// corsConfiguration.addAllowedOrigin("http://localhost:9000");
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", buildConfig());
return new CorsFilter(source);
}
}
此时,Console不会报错,并且Network的Response Headers中Access-Control-xxx有Access-Control-Allow-Credentials:true和Access-Control-Allow-Origin:http://localhost:9000。
4、调试
首先启动后端程序,然后运行npm run dev。前端页面的修改会自动热更新。
5、部署前端代码
运行npm run build,会在webpack.config.js文件中output指定的路径生成js文件,最终的程序运行也只是依赖这个js文件,与之前的vue等资源文件没有关联。
21.1.5 - vue快速入门
作为一个校招后端新人,初入美团接手的第一个任务就是改造后端3个后台管理系统,作为一个只懂html/js/css没有接触过前端框架的新人来说,如何在独立的情况下解决新老项目的环境问题、配置问题,需要学习的东西一定不少,也是一个短期内比较大的挑战吧。 我的经验是:不要无头苍蝇一样从局部的视角去寻求突破,治标不治本。从头学习也没有需要太多时间,比如这个vue找对了方法可能仅需要2-3天,远没有想象的长达几个星期之恐怖。
开发环境
Expand/Collapse Code Block
| |
!!需知 1、npm都可以替换成cnpm,cnpm为国内淘宝镜像,安装包的速度更快,本文为了书写方便只使用npm,当npm安装包的速度过慢,可使用control+c组合键停止安装,转用cnpm。
2、使用npm安装项目所需的包(不是全局的包)时一定要切换至package.json同目录
安装第三方包
| |
npm install命令参数简写方式
| |
如何创建vue项目
需要全局安装脚手架,其命令行工具可以快速搭建大型单页应用。旧版:vue-cli,新版:vue cli3,暂时推荐使用旧版
1、Vue CLI旧版本安装与使用(推荐)
1)安装
| |
2)创建项目
| |
一般使用webpack或webpack-simple两种模板。 (1) 使用webpack模板创建:
| |
执行上述代码后,等待下载完模版,需要进入一系列的配置:
- ? Project name vuedemo01 项目描述
- ? Project description A Vue.js project 项目描述
- ? Author huangbo16 作者
- ? Vue build standalone 编译
- ? Install vue-router? Yes 是否安装路由
- ? Use ESLint to lint your code? Yes 是否使用ESLint管理代码,ESLint是个代码风格管理工具,用来统一代码风格,不会影响整体的运行,为了多人协作。
- ? Pick an ESLint preset Standard 选择一个ESLint预设,编写vue项目时的代码风格(standard: js的标准风格; )
- ? Set up unit tests Yes 是否进行单元测试?
- ? Pick a test runner jest 选择测试框架
- ? Setup e2e tests with Nightwatch? Yes 是否进行端对端测试?
- ? Should we run
npm installfor you after the project has been created? (recommended) npm 使用npm或yarn安装包
(2) 使用webpack-simple模板创建(新手入门推荐):
| |
同样地,需要进行一些配置:
- ? Project name vuedemo02 项目名称
- ? Project description A Vue.js project 项目描述
- ? Author huangbo16 作者
- ? License MIT 许可证
- ? Use sass? Yes 是否使用sass
3)安装依赖包并运行项目
创建完项目后,还需要安装vue依赖的必须包(如果运行别人的程序,从此步开始执行操作即可)
| |
程序一般会自动跳转浏览器打开web页面,如果没有,根据终端输出信息打开web页面
2、Vue CLI3的安装与使用(暂不推荐,简单介绍)
1)安装
| |
2)创建项目
| |
vue项目结构
1、node_modules
项目需要的各种依赖包npm install安装包的路径
2、src
- assets
- App.vue
- main.js
main.js可以修改默认启动的页面,如将App.vue修改成自己创建的vue
3、.babelrc
解码器配置文件。Babel是一个广泛使用的转码器,可以将ES6代码转为ES5代码,从而在现有环境执行。
4、index.html
5、package.json
管理项目所需要的模块,依赖包清单及版本
6、webpack.config.js
webpack配置文件,将业务中各种格式的文件打包成浏览器能够解析的文件
调试与部署步骤**
调试
1、运行后台接口程序,不涉及后台修改不需要重新运行
2、安装完vue依赖包之后,切换至项目目录(package.json同一目录)运行项目(npm run dev)
!!!注意:这里使用的是npm run dev,如果使用npm run build可能会使修改的页面无法热更新,导致无法调试
热更新过程中无需重复执行上述命令,终端会自动检查文件变化、检查语法问题然后热更新,注意观察终端输出信息
3、直接修改前端代码,会自动热更新
错误做法:使用npm run build结合后台程序反复重启部署(如反复重启tomcat),这可能会导致前端资源文件无法热更新,且效率低下。
部署
1、配置webpack.config.js文件中output
Expand/Collapse Code Block
| |
2、切换目录到webpack.config.js文件同目录
| |
这时会将vue等文件写入output配置的js文件中。按这里配置的会在../../springbootdemo/src/main/resources/static生成index.js文件。 使用默认配置会在根目录下生成dist文件夹及build.js文件。
3、在index.html文件中引用index.js
注意:如果使用了webpack.config.js中配置了输出路径,则这里的index.html及index.js不是vue项目中的文件,而是配置的输出路径中的文件。
| |
启动tomcat或其他后台程序访问index.html即可。 需要注意的问题:
npm run dev 运行的index.html是从devServer配置的contentBase目录下的,如果没有配置该目录,则运行本地目录下的。并且最终执行的index.html中引用的js文件路径与名称要与output中配置的一一对应,否则,页面将显示为空白。npm run dev并不会产生js文件。
vue文件结构
注意需要使用
包裹Expand/Collapse Code Block
| |
vue绑定属性
代码:
Expand/Collapse Code Block
| |
注意:如果报Can't resolve 'sass-loader'的错误,这是因为没有安装sass,将格式即可。
vue绑定事件
Expand/Collapse Code Block
| |
v-bind vs v-model
v-bind是数据绑定,没有双向绑定效果,但不一定在表单元素上使用,任何有效元素上都可以使用;
v-model主要是用在表单元素中,实现了*双向绑定。*基本上只会用在input, textarea, select这些表单元素上。
v-model其实是v-bind和v-on的语法糖。当v-bind和v-model同时用在一个元素上时,它们各自的作用没变,但v-model优先级更高,而且需区分这个元素是单个的还是一组出现的。
计算属性与侦听器
- computed中的计算属性(类似方法)会随着其中的data数据变化而返回不同的值,每个计算属性都包含一个getter和一个setter。一个可使用默认的getter方法来读取一个计算属性
- watch用来监视数据的变化,然后调用定义的方法
Expand/Collapse Code Block
| |
vue组件
1、组件的调用
Parent.vue
Expand/Collapse Code Block
| |
Child.vue
| |
2、父组件给子组件传值
a、父组件调用子组件时,绑定动态属性
| |
b、子组件通过props接收父组件传来的数据和方法 c、子组件直接使用父组件传来的数据和方法
代码:
Parent.vue
Expand/Collapse Code Block
| |
Child.vueExpand/Collapse Code Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div>
<h2>Child组件-----{{info}}----{{parentmsg}}</h2>
<button @click="fun1('123')">执行父组件的方法</button>
<br>
<button @click="getParent()">获取父组件的数据和方法</button>
</div>
</template>
<script>
export default{
props:['info','parentmsg','fun1','parent'],
data(){
return{
msg:'子组件的msg'
}
},
methods:{
getParent(){
// alert(this.info);
// alert(this.parent.info);
this.parent.fun1(345);
}
},
}
</script>
3、父组件主动获取子组件的数据和方法
a、调用子组件定义ref
| |
b、在父组件中通过refs使用,具体如下:
| |
4、子组件主动获取父组件的数据和方法
无需父组件给子组件传入数据与方法
| |
5、非父子组件传值(广播)
(需结合路由演示)
a、新建js文件,引入vue并实例化vue,然后暴露这个实例
| |
b、在需要广播的地方引入上述定义的实例
| |
c、在发送数据的地方通过$emit发送广播
| |
d、在接收数据的地方通过$on接收广播
| |
vue生命周期函数
vue生命周期图

示例代码
Expand/Collapse Code Block
| |
!!注意:在mounted函数中直接赋值给data return中的变量无效,因为此时mounted无法获取data中的值。例外:在mounted中发起请求数据可以给data中的变量赋值。
本地存储
1、创建model/storage.js
| |
2、vue中引用storage
Expand/Collapse Code Block
| |
在输入框中输入数据,然后点击添加按钮,会实时刷新数据,重新刷新页面(cmd+r)会发现添加的数据依然在列表中。
路由及跳转
1、创建组件Child.vue
2、安装vue-router
| |
3、在main.js中引入路由和组件
| |
4、在main.js中配置路由
| |
5、实例化VueRouter
| |
!!!!注意:这里routes: routes中第一个routes是固定写法,第二个routes为第3步定义的变量,只有在第二个变量名称为routes的时候才能够使用简写形式。vue的开发过程中还有很多类似的简写形式,需要引起注意。
6、挂载路由
| |
!!!注意:这里的router简写形式同理! 7、在App.vue中定义路由
| |
动态路由
1、配置动态路由
| |
2、在跳转后的vue页面中获取值
| |
vue-resource请求数据
使用方法:
1、安装
| |
2、在main.js导入并use
| |
3、在组件里直接使用(全局)
| |
请求跨域问题
1、前端配置
尝试了官网以及第三方网站很多方法,都以失败告终,原因不详。这里采用设置全局拦截http请求的方法。在main.js中添加如下代码即可:
Expand/Collapse Code Block
| |
2、后端配置
不同版本spring/springboot的默认配置可能支持跨域,如果Console中报错:
| |
此时Network的Response Headers中Access-Control-xxx只有 Access-Control-Allow-Origin: * ,则需要后端配置。 参考链接为:
Expand/Collapse Code Block
| |
此时,Console不会报错,并且Network的Response Headers中Access-Control-xxx有Access-Control-Allow-Credentials:true和Access-Control-Allow-Origin:http://localhost:9000。
axios请求数据
1、安装
| |
2、局部引用局部使用
Expand/Collapse Code Block
| |
vuex介绍
简介:
Vuex解决了组件之间共享数据的问题和组件使用数据的持久化问题,能够集中式管理状态存储。
使用步骤:
1、安装vuex
| |
2、创建一个vuex/xxxstore.js
Expand/Collapse Code Block
| |
3、组件(.vue文件)使用vuex
| |
4、组件(.vue)注册vuex
| |
5、组件(.vue)使用map直接引用store中的数据与方法
Expand/Collapse Code Block
| |
vuex多模块
1、创建模块
users.js、shops.js、dishs.js,
下面是users.js的代码,其它大致相同
Expand/Collapse Code Block
| |
2、创建store(文件名任意,这里为store.js),引用上述模板
Expand/Collapse Code Block
| |
3、在vue页面引用store
Expand/Collapse Code Block
| |
如果在main.js中引用store,则全局使用,无需在每个vue页面中引用。
| |
UI框架Element UI
Elemenet UI简介:
这是饿了么公司开发的基于vue框架的UI组件,具体细节使用可参考官网
1、安装
| |
2、在项目中main.js导入
| |
3、在webpack.config.js中配置file_loader
| |
4、按照官网使用所需组件
时间处理插件momentJS
这里主要介绍使用momentJS实现中文的时间计算,比如计算时间差显示“刚刚”、“几小时前”、“几天前”或“几年前”等等。其它功能暂不介绍。
使用步骤:
1、安装
| |
2、在main.js引入moment,
并设置语言为中文
| |
3、定义一个全局过滤函数
| |
4、在vue页面中使用
| |
21.1.6 - vue显示数值小数点
一、html中使用
| |
二、过滤器
1、局部过滤器
在当前vue页面的script的export default中定义:
| |
2、全局过滤器
在全局的index.js中定义:
| |
22 - 通用
Introduction
通用技术,比如通用方案设计、序列化基础知识以及其它等
22.1 - cron表达式
简介
Quartz Cron表达式主要用于JAVA Spring定时任务中,用法如下:
Expand/Collapse Code Block
| |
各字段的含义
| 字段 | 允许值 | 允许的特殊字符 |
|---|---|---|
| 秒(Seconds) | 0~59的整数 | , - * / 四个字符 |
| 分(Minutes) | 0~59的整数 | , - * / 四个字符 |
| 小时(Hours) | 0~23的整数 | , - * / 四个字符 |
| 日期(DayofMonth) | 1~31的整数(但是你需要考虑你月的天数) | ,- * ? / L W C 八个字符 |
| 月份(Month) | 1~12的整数或者 JAN-DEC | , - * / 四个字符 |
| 星期(DayofWeek) | 1~7的整数或者 SUN-SAT (1=SUN) | , - * ? / L C # 八个字符 |
| 年(可选,留空)(Year) | 1970~2099 | , - * / 四个字符 |
注意事项:
每一个域都使用数字,但还可以出现如下特殊字符,它们的含义是:
(1):表示匹配该域的任意值。假如在Minutes域使用, 即表示每分钟都会触发事件。
(2)?:只能用在DayofMonth(日)和DayofWeek(星期)两个域。表示不指定!它也匹配域的任意值,但实际不会。因为DayofMonth和DayofWeek会相互影响。例如想在每月的20日触发调度,不管20日到底是星期几,则只能使用如下写法: 13 13 15 20 * ?, 其中最后一位只能用?,而不能使用*,如果使用*表示不管星期几都会触发,实际上并不是这样。
(3)-:表示范围。例如在Minutes域使用5-20,表示从5分到20分钟每分钟触发一次
(4)/:表示起始时间开始触发,然后每隔固定时间触发一次。例如在Minutes域使用5/20,则意味着5分钟触发一次,而间隔20分钟在25、45等分别触发一次.
(5),:表示列出枚举值。例如:在Minutes域使用5,20,则意味着在5和20分每分钟触发一次。
(6)L:表示最后,只能出现在DayofWeek和DayofMonth域。如果在DayofWeek域使用5L,意味着在最后的一个星期四触发。
(7)W:表示有效工作日(周一到周五),只能出现在DayofMonth域,系统将在离指定日期的最近的有效工作日触发事件。例如:在 DayofMonth使用5W,如果5日是星期六,则将在最近的工作日:星期五,即4日触发。如果5日是星期天,则在6日(周一)触发;如果5日在星期一到星期五中的一天,则就在5日触发。另外一点,W的最近寻找不会跨过月份 。
(8)LW:这两个字符可以连用,表示在某个月最后一个工作日,即最后一个星期五。
(9)#:用于确定每个月第几个星期几,只能出现在DayofWeek域。例如在4#2,表示某月的第二个星期三。
常用表达式
(0)0/20 * * * * ? 表示每20秒 调整任务
(1)0 0 2 1 * ? 表示在每月的1日的凌晨2点调整任务
(2)0 15 10 ? * MON-FRI 表示周一到周五每天上午10:15执行作业
(3)0 15 10 ? 6L 2002-2006 表示2002-2006年的每个月的最后一个星期五上午10:15执行作
(4)0 0 10,14,16 * * ? 每天上午10点,下午2点,4点
(5)0 0/30 9-17 * * ? 朝九晚五工作时间内每半小时
(6)0 0 12 ? * WED 表示每个星期三中午12点
(7)0 0 12 * * ? 每天中午12点触发
(8)**0 15 10 ? * *** 每天上午10:15触发
(9)0 15 10 * * ? 每天上午10:15触发
(10)**0 15 10 * * ? *** 每天上午10:15触发
(11)0 15 10 * * ? 2005 2005年的每天上午10:15触发
(12)0 * 14 * * ? 在每天下午2点到下午2:59期间的每1分钟触发
(13)0 0/5 14 * * ? 在每天下午2点到下午2:55期间的每5分钟触发
(14)0 0/5 14,18 * * ? 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发
(15)0 0-5 14 * * ? 在每天下午2点到下午2:05期间的每1分钟触发
(16)0 10,44 14 ? 3 WED 每年三月的星期三的下午2:10和2:44触发
(17)0 15 10 ? * MON-FRI 周一至周五的上午10:15触发
(18)0 15 10 15 * ? 每月15日上午10:15触发
(19)0 15 10 L * ? 每月最后一日的上午10:15触发
(20)0 15 10 ? * 6L 每月的最后一个星期五上午10:15触发
(21)0 15 10 ? * 6L 2002-2005 2002年至2005年的每月的最后一个星期五上午10:15触发
(22)0 15 10 ? * 6#3 每月的第三个星期五上午10:15触发
注:
(1)有些子表达式能包含一些范围或列表
例如:子表达式(天(星期))可以为 “MON-FRI”,“MON,WED,FRI”,“MON-WED,SAT”
“*”字符代表所有可能的值
因此,“”在子表达式(月)里表示每个月的含义,“”在子表达式(天(星期))表示星期的每一天
“/”字符用来指定数值的增量
例如:在子表达式(分钟)里的“0/15”表示从第0分钟开始,每15分钟
在子表达式(分钟)里的“3/20”表示从第3分钟开始,每20分钟(它和“3,23,43”)的含义一样
“?”字符仅被用于天(月)和天(星期)两个子表达式,表示不指定值
当2个子表达式其中之一被指定了值以后,为了避免冲突,需要将另一个子表达式的值设为“?”
“L” 字符仅被用于天(月)和天(星期)两个子表达式,它是单词“last”的缩写
但是它在两个子表达式里的含义是不同的。
在天(月)子表达式中,“L”表示一个月的最后一天
在天(星期)自表达式中,“L”表示一个星期的最后一天,也就是SAT
如果在“L”前有具体的内容,它就具有其他的含义了
例如:“6L”表示这个月的倒数第6天,“FRIL”表示这个月的最一个星期五
注意:在使用“L”参数时,不要指定列表或范围,因为这会导致问题
Reference
22.2 - 架构设计01-基础架构
前言
架构的特性
架构设计的思维和程序设计的思维差异很大。
架构设计的关键思维是判断和取舍,程序设计的关键思维是逻辑和实现。
架构的定义
3个问题:
1、微信有架构,微信的登录系统也有架构,微信的支付系统也有架构,当谈微信架构时,到底是在谈什么架构?
2、Linux 有架构,MySQL 有架构,JVM 也有架构,使用 Java 开发、MySQL 存储、跑在 Linux 上的业务系统也有架构,应该关注哪个架构呢?
3、架构和框架是什么关系?有什么区别?
要想准确地理解架构的定义,关键就在于把三组容易混淆的概念梳理清楚:
1、系统与子系统
2、模块与组件
3、框架与架构
系统与子系统
维基百科定义的“系统”:系统泛指由一群有关联的个体组成,根据某种规则运作,能完成个别元件不能单独完成的工作的群体。它的意思是“总体”“整体”或“联盟”。
关联:系统是由一群有关联的个体组成的,没有关联的个体堆在一起不能成为一个系统。例如,把一个发动机和一台 PC 放在一起不能称之为一个系统,把发动机、底盘、轮胎、车架组合起来才能成为一台汽车。
规则:系统内的个体需要按照指定的规则运作,而不是单个个体各自为政。规则规定了系统内个体分工和协作的方式。例如,汽车发动机负责产生动力,然后通过变速器和传动轴,将动力输出到车轮上,从而驱动汽车前进。
能力:系统能力与个体能力有本质的差别,系统能力不是个体能力之和,而是产生了新的能力。例如,汽车能够载重前进,而发动机、变速器、传动轴、车轮本身都不具备这样的能力。
子系统的定义:子系统也是由一群有关联的个体所组成的系统,多半会是更大系统中的一部分。
问题1:
架构定义:只包含顶层这一层级的架构,不包含子系统层级的架构。所以微信架构,就是指微信系统这个层级的架构。当然,微信的子系统,比如支付系统,也有它自己的架构,同样只包括顶层。
模块与组件
软件模块(Module)是一套一致而互相有紧密关连的软件组织。它分别包含了程序和数据结构两部分。现代软件开发往往利用模块作为合成的单位。模块的接口表达了由该模块提供的功能和调用它时所需的元素。模块是可能分开被编写的单位。这使它们可再用和允许人员同时协作、编写及研究不同的模块。
软件组件定义为自包含的、可编程的、可重用的、与语言无关的软件单元,软件组件可以很容易被用于组装应用程序中。
模块和组件都是系统的组成部分,只是从不同的角度拆分系统而已。
从业务逻辑的角度来拆分系统后,得到的单元就是“模块”;从物理部署的角度来拆分系统后,得到的单元就是“组件”。划分模块的主要目的是职责分离;划分组件的主要目的是单元复用。
以一个最简单的网站系统来为例。假设我们要做一个学生信息管理系统,这个系统从逻辑的角度来拆分,可以分为“登录注册模块”“个人信息模块”和“个人成绩模块”;从物理的角度来拆分,可以拆分为 Nginx、Web 服务器和 MySQL。
问题2:
业务系统的架构师,首先需要思考怎么从业务逻辑的角度把系统拆分成一个个模块角色,其次需要思考怎么从物理部署的角度把系统拆分成组件角色,例如选择 MySQL 作为存储系统。但是对于 MySQL 内部的体系架构(Parser、Optimizer、Caches&Buffers 和 Storage Engines 等),其实是可以不用关注的,也不需要在你的业务系统架构中展现这些内容。
框架与架构
软件框架(Software framework)通常指的是为了实现某个业界标准或完成特定基本任务的软件组件规范,也指为了实现某个软件组件规范时,提供规范所要求之基础功能的软件产品。
提炼一下其中关键部分:
1、框架是组件规范:例如,MVC 就是一种最常见的开发规范,类似的还有 MVP、MVVM、J2EE 等框架。
2、框架提供基础功能的产品:例如,Spring MVC 是 MVC 的开发框架,除了满足 MVC 的规范,Spring 提供了很多基础功能来帮助我们实现功能,包括注解(@Controller 等)、Spring Security、Spring JPA 等很多基础功能。
软件架构指软件系统的“基础结构”,创造这些基础结构的准则,以及对这些结构的描述。
框架关注的是“规范”,架构关注的是“结构”。
框架的英文是 Framework,架构的英文是 Architecture,Spring MVC 的英文文档标题就是“Web MVC framework”。
举例:学生管理系统
从业务逻辑的角度分解,架构是 登录注册模块、个人信息模块、个人成绩模块……
从物理部署的角度分解,架构是 Nginx、Web服务器、MySQL
从开发规范的角度分解,架构是 MVC 架构(Controller、View、Model)
问题3:
框架是一整套开发规范,架构是某一套开发规范下的具体落地方案,包括各个模块之间的组合关系以及它们协同起来完成功能的运作规则。
重新定义架构:4R架构
参考维基百科的定义,再结合自己的一些理解和思考,将软件架构重新定义为:软件架构指软件系统的顶层(Rank)结构,它定义了系统由哪些角色(Role)组成,角色之间的关系(Relation)和运作规则(Rule)。

Rank
它是指软件架构是分层的,对应“系统”和“子系统”的分层关系。通常情况下,只需要关注某一层的架构,最多展示相邻两层的架构,而不需要把每一层的架构全部糅杂在一起。无论是架构设计还是画架构图,都应该采取“自顶向下,逐步细化”的方式。以微信为例,Rank 的含义如下所示:

Role
它是指软件系统包含哪些角色,每个角色都会负责系统的一部分功能。架构设计最重要的工作之一就是将系统拆分为多个角色。最常见的微服务拆分其实就是将整体复杂的业务系统按照业务领域的方式,拆分为多个微服务,每个微服务就是系统的一个角色。
Relation
它是指软件系统的角色之间的关系,对应到架构图中其实就是连接线,角色之间的关系不能乱连,任何关系最后都需要代码来实现,包括连接方式(HTTP、TCP、UDP 和串口等)、数据协议(JSON、XML 和二进制等)以及具体的接口等。
Rule
它是指软件系统角色之间如何协作来完成系统功能。在前面解读什么是“系统”的时候提到过:系统能力不是个体能力之和,而是产生了新的能力。那么这个新能力具体如何完成的呢?具体哪些角色参与了这个新能力呢?这就是 Rule 所要表达的内容。在架构设计的时候,核心的业务场景都需要设计 Rule。
实际工作中,为了方便理解,Rank、Role 和 Relation 是通过系统架构图来展示的,而 Rule 是通过系统序列图(System Sequence Diagram)来展示的。
以一个简化的支付系统为例,支付系统架构图如下所示:

“扫码支付”这个核心场景的系统序列图如下所示:

结构设计的历史背景
第一次软件危机与结构化程序设计
20 世纪 60 年代~20 世纪 70 年代。
高级语言的出现,解放了程序员,但好景不长,随着软件的规模和复杂度的大大增加,20 世纪 60 年代中期开始爆发了第一次软件危机,典型表现有软件质量低下、项目无法如期完成、项目严重超支等,因为软件而导致的重大事故时有发生。例如,1963 年美国(http://en.wikipedia.org/wiki/Mariner_1)的水手一号火箭发射失败事故,就是因为一行 FORTRAN 代码错误导致的。
“结构化程序设计”作为另外一种解决软件危机的方案被提了出来。艾兹赫尔·戴克斯特拉(Edsger Dijkstra)于 1968 年发表了著名的《GOTO 有害论》论文,引起了长达数年的论战,并由此产生了结构化程序设计方法。同时,第一个结构化的程序语言 Pascal 也在此时诞生,并迅速流行起来。
结构化程序设计的主要特点是抛弃 goto 语句,采取“自顶向下、逐步细化、模块化”的指导思想。结构化程序设计本质上还是一种面向过程的设计思想,但通过“自顶向下、逐步细化、模块化”的方法,将软件的复杂度控制在一定范围内,从而从整体上降低了软件开发的复杂度。结构化程序方法成为了 20 世纪 70 年代软件开发的潮流。
第二次软件危机与面向对象
结构化编程的风靡在一定程度上缓解了软件危机,然而随着硬件的快速发展,业务需求越来越复杂,以及编程应用领域越来越广泛,第二次软件危机很快就到来了。
第二次软件危机的根本原因还是在于软件生产力远远跟不上硬件和业务的发展。第一次软件危机的根源在于软件的“逻辑”变得非常复杂,而第二次软件危机主要体现在软件的“扩展”变得非常复杂。结构化程序设计虽然能够解决(也许用“缓解”更合适)软件逻辑的复杂性,但是对于业务变化带来的软件扩展却无能为力,软件领域迫切希望找到新的银弹来解决软件危机,在这种背景下,面向对象的思想开始流行起来。
面向对象的思想并不是在第二次软件危机后才出现的,早在 1967 年的 Simula 语言中就开始提出来了,但第二次软件危机促进了面向对象的发展。面向对象真正开始流行是在 20 世纪 80 年代,主要得益于 C++ 的功劳,后来的 Java、C# 把面向对象推向了新的高峰。到现在为止,面向对象已经成为了主流的开发思想。
软件架构的历史背景
虽然早在 20 世纪 60 年代,戴克斯特拉这位上古大神就已经涉及软件架构这个概念了,但软件架构真正流行却是从 20 世纪 90 年代开始的,由于在 Rational 和 Microsoft 内部的相关活动,软件架构的概念开始越来越流行了。
卡内基·梅隆大学的玛丽·肖(Mary Shaw)和戴维·加兰(David Garlan)对软件架构做了很多研究,他们在 1994 年的一篇文章《软件架构介绍》(An Introduction to Software Architecture)中写到:“When systems are constructed from many components, the organization of the overall system-the software architecture-presents a new set of design problems.”
简单翻译一下:随着软件系统规模的增加,计算相关的算法和数据结构不再构成主要的设计问题;当系统由许多部分组成时,整个系统的组织,也就是所说的“软件架构”,导致了一系列新的设计问题。
这段话很好地解释了“软件架构”为何先在 Rational 或者 Microsoft 这样的大公司开始逐步流行起来。因为只有大公司开发的软件系统才具备较大规模,而只有规模较大的软件系统才会面临软件架构相关的问题,例如:
1、系统规模庞大,内部耦合严重,开发效率低;
2、系统耦合严重,牵一发动全身,后续修改和扩展困难;
3、系统逻辑复杂,容易出问题,出问题后很难排查和修复。
软件架构的出现有其历史必然性。20 世纪 60 年代第一次软件危机引出了“结构化编程”,创造了“模块”概念;20 世纪 80 年代第二次软件危机引出了“面向对象编程”,创造了“对象”概念;到了 20 世纪 90 年代“软件架构”开始流行,创造了“组件”概念。我们可以看到,“模块”“对象”“组件”本质上都是对达到一定规模的软件进行拆分,差别只是在于随着软件的复杂度不断增加,拆分的粒度越来越粗,拆分的层次越来越高。
为何结构化编程、面向对象编程、软件工程、架构设计最后都没有成为软件领域的银弹?
架构设计的目的
架构设计的误区
关于架构设计的目的,常见的误区有:
1、因为架构很重要,所以要做架构设计
2、不是每个系统都要做架构设计吗
3、公司流程要求系统开发过程中必须有架构设计
4、为了高性能、高可用、可扩展,所以要做架构设计
但往往持有这类观点的架构师和设计师会给项目带来巨大的灾难,这绝不是危言耸听,而是很多实际发生的事情,为什么会这样呢?因为这类架构师或者设计师不管三七二十一,不管什么系统,也不管什么业务,上来就要求“高性能、高可用、高扩展”,结果就会出现架构设计复杂无比,项目落地遥遥无期,团队天天吵翻天……等各种让人抓狂的现象,费尽九牛二虎之力将系统整上线,却发现运行不够稳定,经常出问题,出了问题很难解决,加个功能要改 1 个月……等各种继续让人抓狂的事件。
架构设计的真正目的
架构设计的主要目的是为了解决软件系统复杂度带来的问题。
通过熟悉和理解需求,识别系统复杂性所在的地方,然后针对这些复杂点进行架构设计。
架构设计并不是要面面俱到,不需要每个架构都具备高性能、高可用、高扩展等特点,而是要识别出复杂点然后有针对性地解决问题。
理解每个架构方案背后所需要解决的复杂点,然后才能对比自己的业务复杂点,参考复杂点相似的方案。(有的放矢,而不是贪大求全)
如果系统的复杂度不是在性能这部分,TPS 做到 10 万并没有什么用。
淘宝的架构是为了解决淘宝业务的复杂度而设计的,淘宝的业务复杂度并不就是我们的业务复杂度,绝大多数业务的用户量都不可能有淘宝那么大。
Docker 不是万能的,只是为了解决资源重用和动态分配而设计的,如果我们的系统复杂度根本不是在这方面,引入 Docker 没有什么意义。
简单的复杂度分析案例
分析一个简单的案例,一起来看看如何将“架构设计的真正目的是为了解决软件系统复杂度带来的问题”这个指导思想应用到实践中。
假设需要设计一个大学的学生管理系统,其基本功能包括登录、注册、成绩管理、课程管理等。当我们对这样一个系统进行架构设计的时候,首先应识别其复杂度到底体现在哪里。
性能:一个学校的学生大约 1 ~ 2 万人,学生管理系统的访问频率并不高,平均每天单个学生的访问次数平均不到 1 次,因此性能这部分并不复杂,存储用 MySQL 完全能够胜任,缓存都可以不用,Web 服务器用 Nginx 绰绰有余。
可扩展性:学生管理系统的功能比较稳定,可扩展的空间并不大,因此可扩展性也不复杂。
高可用:学生管理系统即使宕机 2 小时,对学生管理工作影响并不大,因此可以不做负载均衡,更不用考虑异地多活这类复杂的方案了。但是,如果学生的数据全部丢失,修复是非常麻烦的,只能靠人工逐条修复,这个很难接受,因此需要考虑存储高可靠,这里就有点复杂了。需要考虑多种异常情况:机器故障、机房故障,针对机器故障,需要设计 MySQL 同机房主备方案;针对机房故障,需要设计 MySQL 跨机房同步方案。
安全性:学生管理系统存储的信息有一定的隐私性,例如学生的家庭情况,但并不是和金融相关的,也不包含强隐私(例如玉照、情感)的信息,因此安全性方面只要做 3 个事情就基本满足要求了:Nginx 提供 ACL 控制、用户账号密码管理、数据库访问权限控制。
成本:由于系统很简单,基本上几台服务器就能够搞定,对于一所大学来说完全不是问题,可以无需太多关注。
学生管理系统虽然简单,但麻雀虽小五脏俱全,基本上能涵盖软件系统复杂度分析的各个方面,而且绝大部分技术人员都曾经自己设计或者接触过类似的系统,如果将这个案例和自己的经验对比,相信会有更多的收获。
复杂度来源:高性能
技术发展带来了性能上的提升,不一定带来复杂度的提升。例如,硬件存储从纸带→磁带→磁盘→SSD,并没有显著带来系统复杂度的增加。因为新技术会逐步淘汰旧技术,这种情况下我们直接用新技术即可,不用担心系统复杂度会随之提升。只有那些并不是用来取代旧技术,而是开辟了一个全新领域的技术,才会给软件系统带来复杂度,因为软件系统在设计的时候就需要在这些技术之间进行判断选择或者组合。就像汽车的发明无法取代火车,飞机的出现也并不能完全取代火车,所以我们在出行的时候,需要考虑选择汽车、火车还是飞机,这个选择的过程就比较复杂了,要考虑价格、时间、速度、舒适度等各种因素。
软件系统中高性能带来的复杂度主要体现在两方面,一方面是单台计算机内部为了高性能带来的复杂度;另一方面是多台计算机集群为了高性能带来的复杂度。
单机复杂度
计算机内部复杂度最关键的地方就是操作系统。计算机性能的发展本质上是由硬件发展驱动的,尤其是 CPU 的性能发展。著名的“摩尔定律”表明了 CPU 的处理能力每隔 18 个月就翻一番;而将硬件性能充分发挥出来的关键就是操作系统,所以操作系统本身其实也是跟随硬件的发展而发展的,操作系统是软件系统的运行环境,操作系统的复杂度直接决定了软件系统的复杂度。
操作系统和性能最相关的就是进程和线程。
多进程多线程虽然让多任务并行处理的性能大大提升,但本质上还是分时系统,并不能做到时间上真正的并行。解决这个问题的方式显而易见,就是让多个 CPU 能够同时执行计算任务,从而实现真正意义上的多任务并行。目前这样的解决方案有 3 种:SMP(Symmetric Multi-Processor,对称多处理器结构)、NUMA(Non-Uniform Memory Access,非一致存储访问结构)、MPP(Massive Parallel Processing,海量并行处理结构)。其中 SMP 是我们最常见的,目前流行的多核处理器就是 SMP 方案。操作系统发展到现在,如果我们要完成一个高性能的软件系统,需要考虑如多进程、多线程、进程间通信、多线程并发等技术点,而且这些技术并不是最新的就是最好的,也不是非此即彼的选择。在做架构设计的时候,需要花费很大的精力来结合业务进行分析、判断、选择、组合,这个过程同样很复杂。举一个最简单的例子:Nginx 可以用多进程也可以用多线程,JBoss 采用的是多线程;Redis 采用的是单进程,Memcache 采用的是多线程,这些系统都实现了高性能,但内部实现差异却很大。
集群复杂度
通过大量机器来提升性能,并不仅仅是增加机器这么简单,让多台机器配合起来达到高性能的目的,是一个复杂的任务,我针对常见的几种方式简单分析一下。
任务分配
1 台服务器演变为 2 台服务器后,架构上明显要复杂多了,主要体现在:
1、需要增加一个任务分配器,这个分配器可能是硬件网络设备(例如,F5、交换机等),可能是软件网络设备(例如,LVS),也可能是负载均衡软件(例如,Nginx、HAProxy),还可能是自己开发的系统。选择合适的任务分配器也是一件复杂的事情,需要综合考虑性能、成本、可维护性、可用性等各方面的因素。
2、任务分配器和真正的业务服务器之间有连接和交互(即图中任务分配器到业务服务器的连接线),需要选择合适的连接方式,并且对连接进行管理。例如,连接建立、连接检测、连接中断后如何处理等。
3、任务分配器需要增加分配算法。例如,是采用轮询算法,还是按权重分配,又或者按照负载进行分配。如果按照服务器的负载进行分配,则业务服务器还要能够上报自己的状态给任务分配器。
任务分解
通过这种任务分解的方式,能够把原来大一统但复杂的业务系统,拆分成小而简单但需要多个系统配合的业务系统。从业务的角度来看,任务分解既不会减少功能,也不会减少代码量(事实上代码量可能还会增加,因为从代码内部调用改为通过服务器之间的接口调用),那为何通过任务分解就能够提升性能呢?
主要有几方面的因素:
1、简单的系统更加容易做到高性能
2、可以针对单个任务进行扩展
复杂度来源:高可用
高可用的定义。系统无中断地执行其功能的能力,代表系统的可用性程度,是进行系统设计时的准则之一。
系统的高可用方案五花八门,但万变不离其宗,本质上都是通过“冗余”来实现高可用。通俗点来讲,就是一台机器不够就两台,两台不够就四台;一个机房可能断电,那就部署两个机房;一条通道可能故障,那就用两条,两条不够那就用三条(移动、电信、联通一起上)。高可用的“冗余”解决方案,单纯从形式上来看,和之前讲的高性能是一样的,都是通过增加更多机器来达到目的,但其实本质上是有根本区别的:高性能增加机器目的在于“扩展”处理性能;高可用增加机器目的在于“冗余”处理单元。
计算高可用
这里的“计算”指的是业务的逻辑处理。计算有一个特点就是无论在哪台机器上进行计算,同样的算法和输入数据,产出的结果都是一样的。
存储高可用
对于需要存储数据的系统来说,整个系统的高可用设计关键点和难点就在于“存储高可用”。存储与计算相比,有一个本质上的区别:将数据从一台机器搬到到另一台机器,需要经过线路进行传输。
存储高可用的难点不在于如何备份数据,而在于如何减少或者规避数据不一致对业务造成的影响。
分布式领域里面有一个著名的 CAP 定理,从理论上论证了存储高可用的复杂度。也就是说,存储高可用不可能同时满足“一致性、可用性、分区容错性”,最多满足其中两个,这就要求我们在做架构设计时结合业务进行取舍。
高可用状态决策
无论是计算高可用还是存储高可用,其基础都是“状态决策”,即系统需要能够判断当前的状态是正常还是异常,如果出现了异常就要采取行动来保证高可用。如果状态决策本身都是有错误或者有偏差的,那么后续的任何行动和处理无论多么完美也都没有意义和价值。但在具体实践的过程中,恰好存在一个本质的矛盾:通过冗余来实现的高可用系统,状态决策本质上就不可能做到完全正确。下面基于几种常见的决策方式进行详细分析。
独裁式
独裁式决策指的是存在一个独立的决策主体,我们姑且称它为“决策者”,负责收集信息然后进行决策;所有冗余的个体,姑且称它为“上报者”,都将状态信息发送给决策者。
独裁式的决策方式不会出现决策混乱的问题,因为只有一个决策者,但问题也正是在于只有一个决策者。当决策者本身故障时,整个系统就无法实现准确的状态决策。如果决策者本身又做一套状态决策,那就陷入一个递归的死循环了。
协商式
协商式决策指的是两个独立的个体通过交流信息,然后根据规则进行决策,最常用的协商式决策就是主备决策。
民主式
民主式决策指的是多个独立的个体通过投票的方式来进行状态决策。例如,ZooKeeper 集群在选举 leader 时就是采用这种方式。
民主式决策和协商式决策比较类似,其基础都是独立的个体之间交换信息,每个个体做出自己的决策,然后按照“多数取胜”的规则来确定最终的状态。不同点在于民主式决策比协商式决策要复杂得多,ZooKeeper 的选举算法 ZAB,绝大部分人都看得云里雾里,更不用说用代码来实现这套算法了。
复杂度来源:可扩展
设计具备良好可扩展性的系统,有两个基本条件:
1、正确预测变化
2、完美应对变化
预测变化
软件系统与硬件或者建筑相比,有一个很大的差异:软件系统在发布后,还可以不断地修改和演进。这就意味着不断有新的需求需要实现。
作为架构师,我们总是试图去预测所有的变化,然后设计完美的方案来应对。
如果每个点都考虑可扩展性,架构师会不堪重负,架构设计也会异常庞大且最终无法落地。但架构师也不能完全不做预测,否则可能系统刚上线,马上来新的需求就需要重构,这同样意味着前期很多投入的工作量也白费了。
综合分析,预测变化的复杂性在于:
1、不能每个设计点都考虑可扩展性。
2、不能完全不考虑可扩展性。
3、所有的预测都存在出错的可能性。
对于架构师来说,如何把握预测的程度和提升预测结果的准确性,是一件很复杂的事情,而且没有通用的标准可以简单套上去,更多是靠自己的经验、直觉。所以架构设计评审的时候,经常会出现两个设计师对某个判断争得面红耳赤的情况,原因就在于没有明确标准,不同的人理解和判断有偏差,而最终又只能选择其中一个判断。
2年法则
根据以往的职业经历和思考,提炼出一个“2 年法则”供你参考:只预测 2 年内的可能变化,不要试图预测 5 年甚至 10 年后的变化。
之所以说要预测 2 年,是因为变化快的行业,能够预测 2 年已经足够了;而变化慢的行业,本身就变化慢,预测本身的意义不大,预测 5 年和预测 2 年的结果是差不多的。所以“2 年法则”在大部分场景下都是适用的。
应对法则
方案一:提炼出“变化层”和“稳定层”
第一种应对变化的常见方案是:将不变的部分封装在一个独立的“稳定层”,将“变化”封装在一个“变化层”(也叫“适配层”)。这种方案的核心思想是通过变化层来隔离变化。
1、变化层和稳定层如何拆分?
对于哪些属于变化层,哪些属于稳定层,很多时候并不是像前面的示例(不同接口协议或者不同数据库)那样明确,不同的人有不同的理解,导致架构设计评审的时候可能吵翻天。
2、变化层和稳定层之间的接口如何设计?
对于稳定层来说,接口肯定是越稳定越好;但对于变化层来说,在有差异的多个实现方式中找出共同点,并且还要保证当加入新的功能时,原有的接口不需要太大修改,这是一件很复杂的事情,所以接口设计同样至关重要。例如,MySQL 的 REPLACE INTO 和 Oracle 的 MERGE INTO 语法和功能有一些差异,那么存储层如何向稳定层提供数据访问接口呢?是采取 MySQL 的方式,还是采取 Oracle 的方式,还是自适应判断?如果再考虑 DB2 的情况呢?
方案二:提炼出“抽象层”和“实现层”
第二种常见的应对变化的方案是:提炼出一个“抽象层”和一个“实现层”。如果说方案一的核心思想是通过变化层来隔离变化,那么方案二的核心思想就是通过实现层来封装变化。
方案二典型的实践就是设计模式和规则引擎。
1写2抄3重构原则
在实际工作中具体如何来应对变化呢?Martin Fowler 在他的经典书籍《重构》中给出一个“Rule of three”的原则,原文是“Three Strikes And You Refactor”,中文一般翻译为“事不过三,三则重构”。
假设创新业务要对接第三方钱包,按照这个原则,就可以这样做:
1 写:最开始你们选择了微信钱包对接,此时不需要考虑太多可扩展性,直接快速对照微信支付的 API 对接即可,因为业务是否能做起来还不确定。
2 抄:后来你们发现业务发展不错,决定要接入支付宝,此时还是可以不考虑可扩展,直接把原来微信支付接入的代码拷贝过来,然后对照支付宝的 API,快速修改上线。
3 重构:因为业务发展不错,为了方便更多用户,你们决定接入银联云闪付,此时就需要考虑重构,参考设计模式的模板方法和策略模式将支付对接的功能进行封装。
复杂度来源:低成本
底成本
低成本给架构设计带来的主要复杂度体现在,往往只有“创新”才能达到低成本目标。这里的“创新”既包括开创一个全新的技术领域(这个要求对绝大部分公司太高),也包括引入新技术,如果没有找到能够解决自己问题的新技术,那么就真的需要自己创造新技术了。
类似的新技术例子很多,如:
1、NoSQL(Memcache、Redis 等)的出现是为了解决关系型数据库无法应对高并发访问带来的访问压力。
2、全文搜索引擎(Sphinx、Elasticsearch、Solr)的出现是为了解决关系型数据库 like 搜索的低效的问题。
3、Hadoop 的出现是为了解决传统文件系统无法应对海量数据存储和计算的问题。
再来举几个业界类似的例子。
1、Facebook 为了解决 PHP 的低效问题,刚开始的解决方案是 HipHop PHP,可以将 PHP 语言翻译为 C++ 语言执行,后来改为 HHVM,将 PHP 翻译为字节码然后由虚拟机执行,和 Java 的 JVM 类似。
2、新浪微博将传统的 Redis/MC + MySQL 方式,扩展为 Redis/MC + SSD Cache + MySQL 方式,SSD Cache 作为 L2 缓存使用,既解决了 MC/Redis 成本过高,容量小的问题,也解决了穿透 DB 带来的数据库访问压力(来源:http://www.infoq.com/cn/articles/weibo-platform-archieture )。
3、Linkedin 为了处理每天 5 千亿的事件,开发了高效的 Kafka 消息系统。其他类似将 Ruby on Rails 改为 Java、Lua + redis 改为 Go 语言实现的例子还有很多。
安全
从技术的角度来讲,安全可以分为两类:一类是功能上的安全,一类是架构上的安全。
功能安全
例如,常见的 XSS 攻击、CSRF 攻击、SQL 注入、Windows 漏洞、密码破解等,本质上是因为系统实现有漏洞,黑客有了可乘之机。黑客会利用各种漏洞潜入系统,这种行为就像小偷一样,黑客和小偷的手法都是利用系统或家中不完善的地方潜入,并进行破坏或者盗取。因此形象地说,功能安全其实就是“防小偷”。
架构安全
如果说功能安全是“防小偷”,那么架构安全就是“防强盗”。强盗会直接用大锤将门砸开,或者用炸药将围墙炸倒;小偷是偷东西,而强盗很多时候就是故意搞破坏,对系统的影响也大得多。因此架构设计时需要特别关注架构安全,尤其是互联网时代,理论上来说系统部署在互联网上时,全球任何地方都可以发起攻击。
传统的架构安全主要依靠防火墙,防火墙最基本的功能就是隔离网络,通过将网络划分成不同的区域,制定出不同区域之间的访问控制策略来控制不同信任程度区域间传送的数据流。
防火墙的功能虽然强大,但性能一般,所以在传统的银行和企业应用领域应用较多。但在互联网领域,防火墙的应用场景并不多。因为互联网的业务具有海量用户访问和高并发的特点,防火墙的性能不足以支撑;尤其是互联网领域的 DDoS 攻击,轻则几 GB,重则几十 GB。2016 年知名安全研究人员布莱恩·克莱布斯(Brian Krebs)的安全博客网站遭遇 DDoS 攻击,攻击带宽达 665Gbps,是目前在网络犯罪领域已知的最大的拒绝服务攻击。这种规模的攻击,如果用防火墙来防,则需要部署大量的防火墙,成本会很高。例如,中高端一些的防火墙价格 10 万元,每秒能抗住大约 25GB 流量,那么应对这种攻击就需要将近 30 台防火墙,成本将近 300 万元,这还不包括维护成本,而这些防火墙设备在没有发生攻击的时候又没有什么作用。也就是说,如果花费几百万元来买这么一套设备,有可能几年都发挥不了任何作用。
就算是公司对钱不在乎,一般也不会堆防火墙来防 DDoS 攻击,因为 DDoS 攻击最大的影响是大量消耗机房的出口总带宽。不管防火墙处理能力有多强,当出口带宽被耗尽时,整个业务在用户看来就是不可用的,因为用户的正常请求已经无法到达系统了。防火墙能够保证内部系统不受冲击,但用户也是进不来的。对于用户来说,业务都已经受到影响了,至于是因为用户自己进不去,还是因为系统出故障,用户其实根本不会关心。
基于上述原因,互联网系统的架构安全目前并没有太好的设计手段来实现,更多地是依靠运营商或者云服务商强大的带宽和流量清洗的能力,较少自己来设计和实现。
规模
很多企业级的系统,既没有高性能要求,也没有双中心高可用要求,也不需要什么扩展性,但往往我们一说到这样的系统,很多人都会脱口而出:这个系统好复杂!为什么这样说呢?关键就在于这样的系统往往功能特别多,逻辑分支特别多。特别是有的系统,发展时间比较长,不断地往上面叠加功能,后来的人由于不熟悉整个发展历史,可能连很多功能的应用场景都不清楚,或者细节根本无法掌握,面对的就是一个黑盒系统,看不懂、改不动、不敢改、修不了,复杂度自然就感觉很高了。
规模带来复杂度的主要原因就是“量变引起质变”,当数量超过一定的阈值后,复杂度会发生质的变化。常见的规模带来的复杂度有:
1、功能越来越多,导致系统复杂度指数级上升
2、数据越来越多,系统复杂度发生质变
与功能类似,系统数据越来越多时,也会由量变带来质变,最近几年火热的“大数据”就是在这种背景下诞生的。大数据单独成为了一个热门的技术领域,主要原因就是数据太多以后,传统的数据收集、加工、存储、分析的手段和工具已经无法适应,必须应用新的技术才能解决。目前的大数据理论基础是 Google 发表的三篇大数据相关论文,其中 Google File System 是大数据文件存储的技术理论,Google Bigtable 是列式数据存储的技术理论,Google MapReduce 是大数据运算的技术理论,这三篇技术论文各自开创了一个新的技术领域。
即使数据没有达到大数据规模,数据的增长也可能给系统带来复杂性。最典型的例子莫过于使用关系数据库存储数据,以 MySQL 为例,MySQL 单表的数据因不同的业务和应用场景会有不同的最优值,但不管怎样都肯定是有一定的限度的,一般推荐在 5000 万行左右。如果因为业务的发展,单表数据达到了 10 亿行,就会产生很多问题。(分库分表)
架构设计三原则
业务千变万化,技术层出不穷,设计理念也是百花齐放,看起来似乎很难有一套通用的规范来适用所有的架构设计场景。但是在研究了架构设计的发展历史、多个公司的架构发展过程(QQ、淘宝、Facebook 等)、众多的互联网公司架构设计后,发现有几个共性的原则隐含其中,这就是:合适原则、简单原则、演化原则,架构设计时遵循这几个原则,有助于你做出最好的选择。
合适原则
合适原则宣言:“合适优于业界领先”。
再好的梦想,也需要脚踏实地实现!这里的“脚踏实地”主要体现在下面几个方面。
1、将军难打无兵之仗
没那么多人,却想干那么多活,是失败的第一个主要原因。
2、罗马不是一天建成的
双 11 做了多少优秀的系统,但经历了什么样的挑战、踩了什么样的坑,只有他们自己知道!这些挑战和踩坑,都是架构设计非常关键的促进因素,单纯靠拍脑袋或者头脑风暴,是不可能和真正实战相比的。
没有那么多积累,却想一步登天,是失败的第二个主要原因。
3、冰山下面才是关键
业界领先的方案其实都是“逼”出来的!简单来说,“业务”发展到一定阶段,量变导致了质变,出现了新的问题,已有的方式已经不能应对这些问题,需要用一种新的方案来解决,通过创新和尝试,才有了业界领先的方案。
没有那么卓越的业务场景,却幻想灵光一闪成为天才,是失败的第三个主要原因。
简单原则
简单原则宣言:“简单优于复杂”。
“复杂”在制造领域代表先进,在建筑领域代表领先,但在软件领域,却恰恰相反,代表的是“问题”。
软件领域的复杂性体现在两个方面:
1、结构的复杂性
2、逻辑的复杂性
为什么复杂的电路就意味更强大的功能,而复杂的架构却有很多问题呢?根本原因在于电路一旦设计好后进入生产,就不会再变,复杂性只是在设计时带来影响;而一个软件系统在投入使用后,后续还有源源不断的需求要实现,因此要不断地修改系统,复杂性在整个系统生命周期中都有很大影响。
演化原则
演化原则宣言:“演化优于一步到位”。
软件架构从字面意思理解和建筑结构非常类似,事实上“架构”这个词就是建筑领域的专业名词,维基百科对“软件架构”的定义中有一段话描述了这种相似性:从和目的、主题、材料和结构的联系上来说,软件架构可以和建筑物的架构相比拟。
对于建筑来说,永恒是主题;而对于软件来说,变化才是主题。
如果没有把握“软件架构需要根据业务发展不断变化”这个本质,在做架构设计的时候就很容易陷入一个误区:试图一步到位设计一个软件架构,期望不管业务如何变化,架构都稳如磐石。
考虑到软件架构需要根据业务发展不断变化这个本质特点,软件架构设计其实更加类似于大自然“设计”一个生物,通过演化让生物适应环境,逐步变得更加强大:
首先,生物要适应当时的环境。
其次,生物需要不断地繁殖,将有利的基因传递下去,将不利的基因剔除或者修复。
第三,当环境变化时,生物要能够快速改变以适应环境变化;如果生物无法调整就被自然淘汰;新的生物会保留一部分原来被淘汰生物的基因。
软件架构设计同样是类似的过程:
首先,设计出来的架构要满足当时的业务需要。
其次,架构要不断地在实际应用过程中迭代,保留优秀的设计,修复有缺陷的设计,改正错误的设计,去掉无用的设计,使得架构逐渐完善。
第三,当业务发生变化时,架构要扩展、重构,甚至重写;代码也许会重写,但有价值的经验、教训、逻辑、设计等(类似生物体内的基因)却可以在新架构中延续。
架构设计原则案例
淘宝
淘宝技术发展主要经历了“个人网站”→“Oracle/ 支付宝 / 旺旺”→“Java 时代 1.0”→“Java 时代 2.0”→“Java 时代 3.0”→“分布式时代”。
个人网站
淘宝当时在初创时,没有过多考虑技术是否优越、性能是否海量以及稳定性如何,主要的考虑因素就是:快!
因为此时业务要求快速上线,时间不等人,等你花几个月甚至十几个月搞出一个强大的系统出来,可能市场机会就没有了,黄花菜都凉了。同样,在考虑如何买的时候,淘宝的决策依据主要也是“快”。
买一个网站显然比做一个网站要省事一些,但是他们的梦想可不是做一个小网站而已,要做大,就不是随便买个就行的,要有比较低的维护成本,要能够方便地扩展和二次开发。
那接下来就是第二个问题:买一个什么样的网站?答案是:轻量一点的,简单一点的。
买一个系统是为了“快速可用”,而买一个轻量级的系统是为了“快速开发”。
淘宝最开始的时候业务要求就是“快”,因此反过来要求技术同样要“快”,业务决定技术,这里架构设计和选择主要遵循的是“合适原则”和“简单原则”。
第一代技术架构

Oracle/支付宝/旺旺
淘宝网推出后,由于正好碰到“非典”,网购很火爆,加上采取了成功的市场运作,流量和交易量迅速上涨,业务发展很快,在 2003 年底,MySQL 已经撑不住了。
一般人或者团队在这个时候,可能就开始优化系统、优化架构、分拆业务了,因为这些是大家耳熟能详也很拿手的动作。那我们来看看淘宝这个时候怎么采取的措施:技术的替代方案非常简单,就是换成 Oracle。换 Oracle 的原因除了它容量大、稳定、安全、性能高,还有人才方面的原因。
除了购买 Oracle,后来为了优化,又买了更强大的存储:后来数据量变大了,本地存储不行了。买了 NAS(Network Attached Storage,网络附属存储),NetApp 的 NAS 存储作为了数据库的存储设备,加上 Oracle RAC(Real Application Clusters,实时应用集群)来实现负载均衡。
为什么淘宝在这个时候继续采取“买”的方式来快速解决问题呢?我们可以从时间上看出端倪:此时离刚上线才半年不到,业务飞速发展,最快的方式支撑业务的发展还是去买。如果说第一阶段买的是“方案”,这个阶段买的就是“性能”,这里架构设计和选择主要遵循的还是“合适原则”和“简单原则”。
第二代技术架构:

脱胎换骨的 Java 时代 1.0
淘宝切换到 Java 的原因很有趣,主要因为找了一个 PHP 的开源连接池 SQL Relay 连接到 Oracle,而这个代理经常死锁,死锁了就必须重启,而数据库又必须用 Oracle,于是决定换个开发语言。最后淘宝挑选了 Java,而且当时挑选 Java,也是请 Sun 公司的人,这帮人很厉害,先是将淘宝网站从 PHP 热切换到了 Java,后来又做了支付宝。
这次切换的最主要原因是因为技术影响了业务的发展,频繁的死锁和重启对用户业务产生了严重的影响,从业务的角度来看这是不得不解决的技术问题。
最初选择 SQL Relay 的原因:但对于 PHP 语言来说,它是放在 Apache 上的,每一个请求都会对数据库产生一个连接,它没有连接池这种功能(Java 语言有 Servlet 容器,可以存放连接池)。那如何是好呢?这帮人打探到 eBay 在 PHP 下面用了一个连接池的工具,是 BEA 卖给他们的。我们知道 BEA 的东西都很贵,我们买不起,于是多隆在网上寻寻觅觅,找到一个开源的连接池代理服务 SQL Relay。
不清楚当时到底有多贵,Oracle 都可以买,连接池买不起 ?所以个人感觉这次切换语言,更多是为以后业务发展做铺垫,毕竟当时 PHP 语言远远没有 Java 那么火、那么好招人。淘宝选择 Java 语言的理由可以从侧面验证这点:Java 是当时最成熟的网站开发语言,它有比较良好的企业开发框架,被世界上主流的大规模网站普遍采用,另外有 Java 开发经验的人才也比较多,后续维护成本会比较低。
综合来看,这次架构的变化没有再简单通过“买”来解决,而是通过重构来解决,架构设计和选择遵循了“演化原则”。
第三代技术架构:

坚若磐石的 Java 时代 2.0
Java 时代 2.0,淘宝做了很多优化工作:数据分库、放弃 EJB、引入 Spring、加入缓存、加入 CDN、采用开源的 JBoss。
为什么在这个时候要做这些动作?原文作者很好地概括了做这些动作的原因:这些杂七杂八的修改,对数据分库、放弃 EJB、引入 Spring、加入缓存、加入 CDN、采用开源的 JBoss,看起来没有章法可循,其实都是围绕着提高容量、提高性能、节约成本来做的。
此时的业务发展情况是这样的:随着数据量的继续增长,到了 2005 年,商品数有 1663 万,PV 有 8931 万,注册会员有 1390 万,这给数据和存储带来的压力依然很大,数据量大,性能就慢。所以“买”也搞不定了。
原有的方案存在固有缺陷,随着业务的发展,已经不是靠“买”就能够解决问题了,此时必须从整个架构上去进行调整和优化。比如说 Oracle 再强大,在做 like 类搜索的时候,也不可能做到纯粹的搜索系统如 Solr、Sphinx 等的性能,因为这是机制决定的。
另外,随着规模的增大,纯粹靠买的一个典型问题开始成为重要的考虑因素,那就是成本。当买一台两台 Oracle 的时候,可能对成本并不怎么关心,但如果要买 100 台 Oracle,成本就是一个关键因素了。这就是“量变带来质变”的一个典型案例,业务和系统发生质变后,架构设计遵循“演化原则”的思想,需要再一次重构甚至重写。
第四代技术架构:

Java 时代 3.0 和分布式时代
Java 时代 3.0 个人认为是淘宝技术飞跃的开始,简单来说就是淘宝技术从商用转为“自研”,典型的就是去 IOE 化。分布式时代个人认为是淘宝技术的修炼成功,到了这个阶段,自研技术已经自成一派,除了支撑本身的海量业务,也开始影响整个互联网的技术发展。
到了这个阶段,业务规模急剧上升后,原来并不是主要复杂度的 IOE 成本开始成为了主要的问题,因此通过自研系统来降低 IOE 的成本,去 IOE 也是系统架构的再一次演化。
手机QQ
注:以下部分内容摘自《QQ 1.4 亿在线背后的故事》。
手机 QQ 的发展历程按照用户规模可以粗略划分为 4 个阶段:十万级、百万级、千万级、亿级,不同的用户规模,IM 后台的架构也不同,而且基本上都是用户规模先上去,然后产生各种问题,倒逼技术架构升级。
十万级 IM 1.X
最开始的手机 QQ 后台只有接入服务器和存储服务器,可以说是简单得不能再简单、普通得不能再普通的一个架构了,因为当时业务刚开始,架构设计遵循的是“合适原则”和“简单原则”。

百万级 IM 2.X
随着业务发展到 2001 年,QQ 同时在线人数也突破了一百万。第一代架构很简单,明显不可能支撑百万级的用户规模,主要的问题有:
1、以接入服务器的内存为例,单个在线用户的存储量约为 2KB,索引和在线状态为 50 字节,好友表 400 个好友 × 5 字节 / 好友 = 2000 字节,大致来说,2GB 内存只能支持一百万在线用户。
2、CPU/ 网卡包量和流量 / 交换机流量等瓶颈。
3、单台服务器支撑不下所有在线用户 / 注册用户。
于是针对这些问题做架构改造,按照“演化原则”的指导进行了重构,重构的方案相比现在来说也还是简单得多,因此当时做架构设计时也遵循了“合适原则”和“简单原则”。IM 2.X 的最终架构如图所示。

千万级 IM 3.X
业务发展到 2005 年,QQ 同时在线人数突破了一千万。第二代架构支撑百万级用户是没问题的,但支撑千万级用户又会产生新问题,表现有:
1、同步流量太大,状态同步服务器遇到单机瓶颈。
2、所有在线用户的在线状态信息量太大,单台接入服务器存不下,如果在线数进一步增加,甚至单台状态同步服务器也存不下。
3、单台状态同步服务器支撑不下所有在线用户。
4、单台接入服务器支撑不下所有在线用户的在线状态信息。
针对这些问题,架构需要继续改造升级,再一次“演化”。IM 3.X 的最终架构如下图,可以看到这次的方案相比之前的方案来说并不简单了,这是业务特性决定的。

亿级 IM 4.X
业务发展到 2010 年 3 月,QQ 同时在线人数过亿。第三代架构此时也不适应了,主要问题有:
1、灵活性很差,比如“昵称”长度增加一半,需要两个月;增加“故乡”字段,需要两个月;最大好友数从 500 变成 1000,需要三个月。
4、无法支撑某些关键功能,比如好友数上万、隐私权限控制、PC QQ 与手机 QQ 不可互踢、微信与 QQ 互通、异地容灾。
除了不适应,还有一个更严重的问题:
IM 后台从 1.0 到 3.5 都是在原来基础上做改造升级的,但是持续打补丁已经难以支撑亿级在线,IM 后台 4.0 必须从头开始,重新设计实现!
这里再次遵循了“演化原则”,决定重新打造一个这么复杂的系统。
重新设计的 IM 4.0 架构如图所示,和之前的架构相比,架构本身都拆分为两个主要的架构:存储架构和通信架构。
存储架构

通讯架构

架构设计流程:识别复杂度
架构设计的本质目的是为了解决软件系统的复杂性,所以在我们设计架构时,首先就要分析系统的复杂性。只有正确分析出了系统的复杂性,后续的架构设计方案才不会偏离方向;否则,如果对系统的复杂性判断错误,即使后续的架构设计方案再完美再先进,都是南辕北辙,做的越好,错的越多、越离谱。
正确的做法是将主要的复杂度问题列出来,然后根据业务、技术、团队等综合情况进行排序,优先解决当前面临的最主要的复杂度问题。
识别复杂度实战
假想一个创业公司,名称叫作“前浪微博”。前浪微博的业务发展很快,系统也越来越多,系统间协作的效率很低,例如:
1、用户发一条微博后,微博子系统需要通知审核子系统进行审核,然后通知统计子系统进行统计,再通知广告子系统进行广告预测,接着通知消息子系统进行消息推送……一条微博有十几个通知,目前都是系统间通过接口调用的。每通知一个新系统,微博子系统就要设计接口、进行测试,效率很低,问题定位很麻烦,经常和其他子系统的技术人员产生分岐,微博子系统的开发人员不胜其烦。
2、用户等级达到 VIP 后,等级子系统要通知福利子系统进行奖品发放,要通知客服子系统安排专属服务人员,要通知商品子系统进行商品打折处理……等级子系统的开发人员也是不胜其烦。
新来的架构师在梳理这些问题时,结合自己的经验,敏锐地发现了这些问题背后的根源在于架构上各业务子系统强耦合,而消息队列系统正好可以完成子系统的解耦,于是提议要引入消息队列系统。经过一分析二讨论三开会四汇报五审批等一系列操作后,消息队列系统终于立项了。其他背景信息还有:
1、中间件团队规模不大,大约 6 人左右。
2、中间件团队熟悉 Java 语言,但有一个新同事 C/C++ 很牛。
3、开发平台是 Linux,数据库是 MySQL。
4、目前整个业务系统是单机房部署,没有双机房。
针对前浪微博的消息队列系统,采用“排查法”来分析复杂度,具体分析过程是:
是否需要高性能
假设前浪微博系统用户每天发送 1000 万条微博,那么微博子系统一天会产生 1000 万条消息,我们再假设平均一条消息有 10 个子系统读取,那么其他子系统读取的消息大约是 1 亿次。
1000 万和 1 亿看起来很吓人,但对于架构师来说,关注的不是一天的数据,而是 1 秒的数据,即 TPS 和 QPS。我们将数据按照秒来计算,一天内平均每秒写入消息数为 115 条,每秒读取的消息数是 1150 条;再考虑系统的读写并不是完全平均的,设计的目标应该以峰值来计算。峰值一般取平均值的 3 倍,那么消息队列系统的 TPS 是 345,QPS 是 3450,这个量级的数据意味着并不要求高性能。
虽然根据当前业务规模计算的性能要求并不高,但业务会增长,因此系统设计需要考虑一定的性能余量。由于现在的基数较低,为了预留一定的系统容量应对后续业务的发展,我们将设计目标设定为峰值的 4 倍,因此最终的性能要求是:TPS 为 1380,QPS 为 13800。TPS 为 1380 并不高,但 QPS 为 13800 已经比较高了,因此高性能读取是复杂度之一。注意,这里的设计目标设定为峰值的 4 倍是根据业务发展速度来预估的,不是固定为 4 倍,不同的业务可以是 2 倍,也可以是 8 倍,但一般不要设定在 10 倍以上,更不要一上来就按照 100 倍预估。
是否需要高可用性
对于微博子系统来说,如果消息丢了,导致没有审核,然后触犯了国家法律法规,则是非常严重的事情;对于等级子系统来说,如果用户达到相应等级后,系统没有给他奖品和专属服务,则 VIP 用户会很不满意,导致用户流失从而损失收入,虽然也比较关键,但没有审核子系统丢消息那么严重。
综合来看,消息队列需要高可用性,包括消息写入、消息存储、消息读取都需要保证高可用性。
是否需要高可扩展性
消息队列的功能很明确,基本无须扩展,因此可扩展性不是这个消息队列的复杂度关键。
为了方便理解,这里只排查“高性能”“高可用”“扩展性”这 3 个复杂度,在实际应用中,不同的公司或者团队,可能还有一些其他方面的复杂度分析。例如,金融系统可能需要考虑安全性,有的公司会考虑成本等。
架构设计流程:设计备选方案
虽然软件技术经过几十年的发展,新技术层出不穷,但是经过时间考验,已经被各种场景验证过的成熟技术其实更多。例如,高可用的主备方案、集群方案,高性能的负载均衡、多路复用,可扩展的分层、插件化等技术,绝大部分时候我们有了明确的目标后,按图索骥就能够找到可选的解决方案。
只有当这种方式完全无法满足需求的时候,才会考虑进行方案的创新,而事实上方案的创新绝大部分情况下也都是基于已有的成熟技术。
NoSQL:Key-Value 的存储和数据库的索引其实是类似的,Memcache 只是把数据库的索引独立出来做成了一个缓存系统。
Hadoop 大文件存储方案,基础其实是集群方案 + 数据复制方案。
Docker 虚拟化,基础是 LXC(Linux Containers)。
LevelDB 的文件存储结构是 Skip List。
第一种常见的错误:设计最优秀的方案。
第二种常见的错误:只做一个方案。
合理的做法:
1、备选方案的数量以 3 ~ 5 个为最佳。
2、备选方案的差异要比较明显。
3、备选方案的技术不要只局限于已经熟悉的技术。
第三种常见的错误:备选方案过于详细。
弊端显而易见:
1、耗费了大量的时间和精力。
2、将注意力集中到细节中,忽略了整体的技术设计,导致备选方案数量不够或者差异不大。
3、评审的时候其他人会被很多细节给绕进去,评审效果很差。例如针对某个定时器应该是 1 分钟还是 30 秒,争论得不可开交。
正确的做法是备选阶段关注的是技术选型,而不是技术细节,技术选型的差异要比较明显。例如,采用 ZooKeeper 和 Keepalived 两种不同的技术来实现主备,差异就很大;而同样都采用 ZooKeeper,一个方案的节点设计是 /service/node/master,另一个方案的节点设计是 /company/service/master,这两个方案并无明显差异,无须在备选方案设计阶段作为两个不同的备选方案,至于节点路径究竟如何设计,只要在最终的方案中挑选一个进行细化即可。
设计备选方案实战
回到“前浪微博”的场景,之前通过“排查法”识别了消息队列的复杂性主要体现在:高性能消息读取、高可用消息写入、高可用消息存储、高可用消息读取。接下来进行第 2 步,设计备选方案。
1、备选方案 1:采用开源的 Kafka
Kafka 是成熟的开源消息队列方案,功能强大,性能非常高,而且已经比较成熟,很多大公司都在使用。
2、备选方案 2:集群 + MySQL 存储
简单描述一下方案:
1、采用数据分散集群的架构,集群中的服务器进行分组,每个分组存储一部分消息数据。
2、每个分组包含一台主 MySQL 和一台备 MySQL,分组内主备数据复制,分组间数据不同步。
3、正常情况下,分组内的主服务器对外提供消息写入和消息读取服务,备服务器不对外提供服务;主服务器宕机的情况下,备服务器对外提供消息读取的服务。
4、客户端采取轮询的策略写入和读取消息。
3、备选方案 3:集群 + 自研存储方案
在备选方案 2 的基础上,将 MySQL 存储替换为自研实现存储方案,因为 MySQL 的关系型数据库的特点并不是很契合消息队列的数据特点,参考 Kafka 的做法,可以自己实现一套文件存储和复制方案(此处省略具体的方案描述,实际设计时需要给出方案)
架构设计流程:评估和选择备选方案
在完成备选方案设计后,如何挑选出最终的方案也是一个很大的挑战,主要原因有:
1、每个方案都是可行的,如果方案不可行就根本不应该作为备选方案。
2、没有哪个方案是完美的。例如,A 方案有性能的缺点,B 方案有成本的缺点,C 方案有新技术不成熟的风险。
3、评价标准主观性比较强,比如设计师说 A 方案比 B 方案复杂,但另外一个设计师可能会认为差不多,因为比较难将“复杂”一词进行量化。因此,方案评审的时候经常会遇到几个设计师针对某个方案或者某个技术点争论得面红耳赤。
正因为选择备选方案存在这些困难,所以实践中很多设计师或者架构师就采取了下面几种指导思想:
1、最简派
2、最牛派
3、最熟派
4、领导派
前面提到了那么多指导思想,真正应该选择是“360 度环评”!具体的操作方式为:列出需要关注的质量属性点,然后分别从这些质量属性的维度去评估每个方案,再综合挑选适合当时情况的最优方案。
常见的方案质量属性点有:性能、可用性、硬件成本、项目投入、复杂度、安全性、可扩展性等。在评估这些质量属性时,需要遵循架构设计原则 1“合适原则”和原则 2“简单原则”,避免贪大求全,基本上某个质量属性能够满足一定时期内业务发展就可以了。
有时候量变会引起质变,如团队人员增多,在同一个系统上开发效率会变低。单集群机房设计不满足业务需求了,需要升级为异地多活的架构。引入开源方案工作量小,但是可运维性和可扩展性差;自研工作量大,但是可运维和可维护性好;使用 C 语言开发性能高,但是目前团队 C 语言技术积累少;使用 Java 技术积累多,但是性能没有 C 语言开发高,成本会高一些……诸如此类。
面临这种选择上的困难,有几种看似正确但实际错误的做法。
1、数量对比法:简单地看哪个方案的优点多就选哪个。
这种方案主要的问题在于把所有质量属性的重要性等同,而没有考虑质量属性的优先级。例如,对于 BAT 这类公司来说,方案的成本都不是问题,可用性和可扩展性比成本要更重要得多;但对于创业公司来说,成本可能就会变得很重要。
2、加权法
这种方案主要的问题是无法客观地给出每个质量属性的权重得分。
正确的做法是按优先级选择,即架构师综合当前的业务发展情况、团队人员规模和技能、业务发展预测等因素,将质量属性按照优先级排序,首先挑选满足第一优先级的,如果方案都满足,那就再看第二优先级……以此类推。
评估和选择备选方案实战
再回到之前设计的场景“前浪微博”。针对上期提出的 3 个备选方案,架构师组织了备选方案评审会议,参加的人有研发、测试、运维、还有几个核心业务的主管。
1、备选方案 1:采用开源 Kafka 方案
2、备选方案 2:集群 + MySQL 存储
3、备选方案 3:集群 + 自研存储系统
针对 3 个备选方案的讨论初步完成后,架构师列出了 3 个方案的 360 度环评表:

列出这个表格后,无法一眼看出具体哪个方案更合适,于是大家都把目光投向架构师,决策的压力现在集中在架构师身上了。
架构师经过思考后,给出了最终选择备选方案 2,原因有:
1、排除备选方案 1 的主要原因是可运维性,因为再成熟的系统,上线后都可能出问题,如果出问题无法快速解决,则无法满足业务的需求;并且 Kafka 的主要设计目标是高性能日志传输,而我们的消息队列设计的主要目标是业务消息的可靠传输。
2、排除备选方案 3 的主要原因是复杂度,目前团队技术实力和人员规模(总共 6 人,还有其他中间件系统需要开发和维护)无法支撑自研存储系统(参考架构设计原则 2:简单原则)。
3、备选方案 2 的优点就是复杂度不高,也可以很好地融入现有运维体系,可靠性也有保障。
针对备选方案 2 的缺点,架构师解释是:
1、备选方案 2 的第一个缺点是性能,业务目前需要的性能并不是非常高,方案 2 能够满足,即使后面性能需求增加,方案 2 的数据分组方案也能够平行扩展进行支撑(参考架构设计原则 3:演化原则)。
2、备选方案 2 的第二个缺点是成本,一个分组就需要 4 台机器,支撑目前的业务需求可能需要 12 台服务器,但实际上备机(包括服务器和数据库)主要用作备份,可以和其他系统并行部署在同一台机器上。
3、备选方案 2 的第三个缺点是技术上看起来并不很优越,但我们的设计目的不是为了证明自己(参考架构设计原则 1:合适原则),而是更快更好地满足业务需求。
通过这个案例可以看出,备选方案的选择和很多因素相关,并不单单考虑性能高低、技术是否优越这些纯技术因素。业务的需求特点、运维团队的经验、已有的技术体系、团队人员的技术水平都会影响备选方案的选择。因此,同样是上述 3 个备选方案,有的团队会选择引入 Kafka(例如,很多创业公司的初创团队,人手不够,需要快速上线支撑业务),有的会选择自研存储系统(例如,阿里开发了 RocketMQ,人多力量大,业务复杂是主要原因)。
架构设计流程:详细方案设计
简单来说,详细方案设计就是将方案涉及的关键技术细节给确定下来。
1、假如我们确定使用 Elasticsearch 来做全文搜索,那么就需要确定 Elasticsearch 的索引是按照业务划分,还是一个大索引就可以了;副本数量是 2 个、3 个还是 4 个,集群节点数量是 3 个还是 6 个等。
2、假如我们确定使用 MySQL 分库分表,那么就需要确定哪些表要分库分表,按照什么维度来分库分表,分库分表后联合查询怎么处理等。
3、假如我们确定引入 Nginx 来做负载均衡,那么 Nginx 的主备怎么做,Nginx 的负载均衡策略用哪个(权重分配?轮询?ip_hash?)等。
可以看到,详细设计方案里面其实也有一些技术点和备选方案类似。例如,Nginx 的负载均衡策略,备选有轮询、权重分配、ip_hash、fair、url_hash 五个,具体选哪个呢?看起来和备选方案阶段面临的问题类似,但实际上这里的技术方案选择是很轻量级的,我们无须像备选方案阶段那样操作,而只需要简单根据这些技术的适用场景选择就可以了。
详细设计方案阶段可能遇到的一种极端情况就是在详细设计阶段发现备选方案不可行,一般情况下主要的原因是备选方案设计时遗漏了某个关键技术点或者关键的质量属性。例如一个项目,在备选方案阶段确定是可行的,但在详细方案设计阶段,发现由于细节点太多,方案非常庞大,整个项目可能要开发长达 1 年时间,最后只得废弃原来的备选方案,重新调整项目目标、计划和方案。这个项目的主要失误就是在备选方案评估时忽略了开发周期这个质量属性。
幸运的是,这种情况可以通过下面方式有效地避免:
1、架构师不但要进行备选方案设计和选型,还需要对备选方案的关键细节有较深入的理解。
2、通过分步骤、分阶段、分系统等方式,尽量降低方案复杂度,方案本身的复杂度越高,某个细节推翻整个方案的可能性就越高,适当降低复杂性,可以减少这种风险。
3、如果方案本身就很复杂,那就采取设计团队的方式来进行设计,博采众长,汇集大家的智慧和经验,防止只有 1~2 个架构师可能出现的思维盲点或者经验盲区。
详细方案设计实战
下面针对上面的“前浪微博”列出一些备选方案 2 典型的需要细化的设计点供参考。
数据库表如何设计
1、数据库设计两类表,一类是日志表,用于消息写入时快速存储到 MySQL 中;另一类是消息表,每个消息队列一张表。
2、业务系统发布消息时,首先写入到日志表,日志表写入成功就代表消息写入成功;后台线程再从日志表中读取消息写入记录,将消息内容写入到消息表中。
3、业务系统读取消息时,从消息表中读取。
4、日志表表名为 MQ_LOG,包含的字段:日志 ID、发布者信息、发布时间、队列名称、消息内容。
5、消息表表名就是队列名称,包含的字段:消息 ID(递增生成)、消息内容、消息发布时间、消息发布者。
6、日志表需要及时清除已经写入消息表的日志数据,消息表最多保存 30 天的消息数据。
数据如何复制
直接采用 MySQL 主从复制即可,只复制消息存储表,不复制日志表。
主备服务器如何倒换
采用 ZooKeeper 来做主备决策,主备服务器都连接到 ZooKeeper 建立自己的节点,主服务器的路径规则为“/MQ/server/ 分区编号 /master”,备机为“/MQ/server/ 分区编号 /slave”,节点类型为 EPHEMERAL。
备机监听主机的节点消息,当发现主服务器节点断连后,备服务器修改自己的状态,对外提供消息读取服务。
业务服务器如何写入消息
1、消息队列系统设计两个角色:生产者和消费者,每个角色都有唯一的名称。
2、消息队列系统提供 SDK 供各业务系统调用,SDK 从配置中读取所有消息队列系统的服务器信息,SDK 采取轮询算法发起消息写入请求给主服务器。如果某个主服务器无响应或者返回错误,SDK 将发起请求发送到下一台服务器。
业务服务器如何读取消息
1、消息队列系统提供 SDK 供各业务系统调用,SDK 从配置中读取所有消息队列系统的服务器信息,轮流向所有服务器发起消息读取请求。
2、消息队列服务器需要记录每个消费者的消费状态,即当前消费者已经读取到了哪条消息,当收到消息读取请求时,返回下一条未被读取的消息给消费者。
业务和消息队列之间的通信协议如何设计
考虑到消息队列系统后续可能会对接多种不同编程语言编写的系统,为了提升兼容性,传输协议用 TCP,数据格式为 ProtocolBuffer。
当然还有更多设计细节就不再一一列举,因此这还不是一个完整的设计方案,我希望可以通过这些具体实例来说明细化方案具体如何去做。
22.3 - 架构设计02-高性能架构模式
高性能数据集群-读写分离
大部分情况下,我们做架构设计主要都是基于已有的成熟模式,结合业务和团队的具体情况,进行一定的优化或者调整;即使少部分情况我们需要进行较大的创新,前提也是需要对已有的各种架构模式和技术非常熟悉。
虽然近十年来各种存储技术飞速发展,但关系数据库由于其 ACID 的特性和功能强大的 SQL 查询,目前还是各种业务系统中关键和核心的存储系统,很多场景下高性能的设计最核心的部分就是关系数据库的设计。
不管是为了满足业务发展的需要,还是为了提升自己的竞争力,关系数据库厂商(Oracle、DB2、MySQL 等)在优化和提升单个数据库服务器的性能方面也做了非常多的技术优化和改进。但业务发展速度和数据增长速度,远远超出数据库厂商的优化速度,尤其是互联网业务兴起之后,海量用户加上海量数据的特点,单个数据库服务器已经难以满足业务需要,必须考虑数据库集群的方式来提升性能。
高性能数据库集群的第一种方式是“读写分离”,其本质是将访问压力分散到集群中的多个节点,但是没有分散存储压力;第二种方式是“分库分表”,既可以分散访问压力,又可以分散存储压力。
读写分离原理
读写分离的基本原理是将数据库读写操作分散到不同的节点上。
基本架构图如下:

读写分离的基本实现是:
- 数据库服务器搭建主从集群,一主一从、一主多从都可以。
- 数据库主机负责读写操作,从机只负责读操作。
- 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。
- 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。
需要注意的是,这里用的是“主从集群”,而不是“主备集群”。“从机”的“从”可以理解为“仆从”,仆从是要帮主人干活的,“从机”是需要提供读数据的功能的;而“备机”一般被认为仅仅提供备份功能,不提供访问功能。所以使用“主从”还是“主备”,是要看场景的,这两个词并不是完全等同的。
读写分离的实现逻辑并不复杂,但有两个细节点将引入设计复杂度:主从复制延迟和分配机制。
复制延迟
以 MySQL 为例,主从复制延迟可能达到 1 秒,如果有大量数据同步,延迟 1 分钟也是有可能的。主从复制延迟会带来业务上的问题。
解决主从复制延迟有几种常见的方法:
1、写操作后的读操作指定发给数据库主服务器
2、读从机失败后再读一次主机
这就是通常所说的“二次读取”,二次读取和业务无绑定,只需要对底层数据库访问的 API 进行封装即可,实现代价较小,不足之处在于如果有很多二次读取,将大大增加主机的读操作压力。例如,黑客暴力破解账号,会导致大量的二次读取操作,主机可能顶不住读操作的压力从而崩溃。
3、关键业务读写操作全部指向主机,非关键业务采用读写分离
分配机制
将读写操作区分开来,然后访问不同的数据库服务器,一般有两种方式:程序代码封装和中间件封装。
1、程序代码封装
程序代码封装指在代码中抽象一个数据访问层(所以有的文章也称这种方式为“中间层封装”),实现读写操作分离和数据库服务器连接的管理。例如,基于 Hibernate 进行简单封装,就可以实现读写分离,基本架构是:

程序代码封装的方式具备几个特点:
- 实现简单,而且可以根据业务做较多定制化的功能。
- 每个编程语言都需要自己实现一次,无法通用,如果一个业务包含多个编程语言写的多个子系统,则重复开发的工作量比较大。
- 故障情况下,如果主从发生切换,则可能需要所有系统都修改配置并重启。
目前开源的实现方案中,淘宝的 TDDL(Taobao Distributed Data Layer,外号: 头都大了)是比较有名的。它是一个通用数据访问层,所有功能封装在 jar 包中提供给业务代码调用。其基本原理是一个基于集中式配置的 jdbc datasource 实现,具有主备、读写分离、动态数据库配置等功能,基本架构是:

2、中间件封装
中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。其基本架构是:

数据库中间件的方式具备的特点是:
- 能够支持多种编程语言,因为数据库中间件对业务服务器提供的是标准 SQL 接口。
- 数据库中间件要支持完整的 SQL 语法和数据库服务器的协议(例如,MySQL 客户端和服务器的连接协议),实现比较复杂,细节特别多,很容易出现 bug,需要较长的时间才能稳定。
- 数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求也很高。
- 数据库主从切换对业务服务器无感知,数据库中间件可以探测数据库服务器的主从状态。例如,向某个测试表写入一条数据,成功的就是主机,失败的就是从机。
由于数据库中间件的复杂度要比程序代码封装高出一个数量级,一般情况下建议采用程序语言封装的方式,或者使用成熟的开源数据库中间件。如果是大公司,可以投入人力去实现数据库中间件,因为这个系统一旦做好,接入的业务系统越多,节省的程序开发投入就越多,价值也越大。
目前的开源数据库中间件方案中,MySQL 官方先是提供了 MySQL Proxy,但 MySQL Proxy 一直没有正式 GA,现在 MySQL 官方推荐 MySQL Router。MySQL Router 的主要功能有读写分离、故障自动切换、负载均衡、连接池等,其基本架构如下:

奇虎 360 公司也开源了自己的数据库中间件 Atlas,Atlas 是基于 MySQL Proxy 实现的,基本架构如下:

参考地址:https://github.com/Qihoo360/Atlas/wiki/Atlas%E7%9A%84%E6%9E%B6%E6%9E%84
高性能数据集群-分库分表
读写分离分散了数据库读写操作的压力,但没有分散存储压力,当数据量达到千万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:
- 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
- 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
- 数据文件越大,极端情况下丢失数据的风险越高(例如,机房火灾导致数据库主备机都发生故障)。
基于上述原因,单个数据库服务器存储的数据量不能太大,需要控制在一定的范围内。为了满足业务数据存储的需求,就需要将存储分散到多台数据库服务器上。
常见的分散存储的方法“分库分表”,其中包括“分库”和“分表”两大类。
业务分库
**业务分库指的是按照业务模块将数据分散到不同的数据库服务器。**例如,一个简单的电商网站,包括用户、商品、订单三个业务模块,我们可以将用户数据、商品数据、订单数据分开放到三台不同的数据库服务器上,而不是将所有数据都放在一台数据库服务器上。

虽然业务分库能够分散存储和访问压力,但同时也带来了新的问题。
1、join 操作问题
业务分库后,原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查询。
2、事务问题
原本在同一个数据库中不同的表可以在同一个事务中修改,业务分库后,表分散到不同的数据库中,无法通过事务统一修改。虽然数据库厂商提供了一些分布式事务的解决方案(例如,MySQL 的 XA),但性能实在太低,与高性能存储的目标是相违背的。
3、成本问题
业务分库同时也带来了成本的代价,本来 1 台服务器搞定的事情,现在要 3 台,如果考虑备份,那就是 2 台变成了 6 台。
基于上述原因,对于小公司初创业务,并不建议一开始就这样拆分,主要有几个原因:
- 初创业务存在很大的不确定性,业务不一定能发展起来,业务开始的时候并没有真正的存储和访问压力,业务分库并不能为业务带来价值。
- 业务分库后,表之间的 join 查询、数据库事务无法简单实现了。
- 业务分库后,因为不同的数据要读写不同的数据库,代码中需要增加根据数据类型映射到不同数据库的逻辑,增加了工作量。而业务初创期间最重要的是快速实现、快速验证,业务分库会拖慢业务节奏。
有的架构师可能会想:如果业务真的发展很快,岂不是很快就又要进行业务分库了?那为何不一开始就设计好呢?
其实这个问题很好回答,按照前面提到的“架构设计三原则”,简单分析一下。
首先,这里的“如果”事实上发生的概率比较低,做 10 个业务有 1 个业务能活下去就很不错了,更何况快速发展,和中彩票的概率差不多。如果我们每个业务上来就按照淘宝、微信的规模去做架构设计,不但会累死自己,还会害死业务。
其次,如果业务真的发展很快,后面进行业务分库也不迟。因为业务发展好,相应的资源投入就会加大,可以投入更多的人和更多的钱,那业务分库带来的代码和业务复杂的问题就可以通过增加人来解决,成本问题也可以通过增加资金来解决。
第三,单台数据库服务器的性能其实也没有想象的那么弱,一般来说,单台数据库服务器能够支撑 10 万用户量量级的业务,初创业务从 0 发展到 10 万级用户,并不是想象得那么快。
而对于业界成熟的大公司来说,由于已经有了业务分库的成熟解决方案,并且即使是尝试性的新业务,用户规模也是海量的,这与前面提到的初创业务的小公司有本质区别,因此最好在业务开始设计时就考虑业务分库。例如,在淘宝上做一个新的业务,由于已经有成熟的数据库解决方案,用户量也很大,需要在一开始就设计业务分库甚至接下来介绍的分表方案。
分表
将不同业务数据分散存储到不同的数据库服务器,能够支撑百万甚至千万用户规模的业务,但如果业务继续发展,同一业务的单表数据也会达到单台数据库服务器的处理瓶颈。例如,淘宝的几亿用户数据,如果全部存放在一台数据库服务器的一张表中,肯定是无法满足性能要求的,此时就需要对单表数据进行拆分。
单表数据拆分有两种方式:垂直分表和水平分表。示意图如下:

为了形象地理解垂直拆分和水平拆分的区别,可以想象手里拿着一把刀,面对一个蛋糕切一刀:
- 从上往下切就是垂直切分,因为刀的运行轨迹与蛋糕是垂直的,这样可以把蛋糕切成高度相等(面积可以相等也可以不相等)的两部分,对应到表的切分就是表记录数相同但包含不同的列。例如,示意图中的垂直切分,会把表切分为两个表,一个表包含 ID、name、age、sex 列,另外一个表包含 ID、nickname、description 列。
- 从左往右切就是水平切分,因为刀的运行轨迹与蛋糕是平行的,这样可以把蛋糕切成面积相等(高度可以相等也可以不相等)的两部分,对应到表的切分就是表的列相同但包含不同的行数据。例如,示意图中的水平切分,会把表分为两个表,两个表都包含 ID、name、age、sex、nickname、description 列,但是一个表包含的是 ID 从 1 到 999999 的行数据,另一个表包含的是 ID 从 1000000 到 9999999 的行数据。
上面这个示例比较简单,只考虑了一次切分的情况,实际架构设计过程中并不局限切分的次数,可以切两次,也可以切很多次,就像切蛋糕一样,可以切很多刀。
单表进行切分后,是否要将切分后的多个表分散在不同的数据库服务器中,可以根据实际的切分效果来确定,并不强制要求单表切分为多表后一定要分散到不同数据库中。原因在于单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升,如果性能能够满足业务要求,是可以不拆分到多台数据库服务器的,毕竟我们在上面业务分库的内容看到业务分库也会引入很多复杂性的问题;如果单表拆分为多表后,单台服务器依然无法满足性能要求,那就不得不再次进行业务分库的设计了。
分表能够有效地分散存储压力和带来性能提升,但和分库一样,也会引入各种复杂性。
垂直分表
垂直分表适合将表中某些不常用且占了大量空间的列拆分出去。
垂直分表引入的复杂性主要体现在表操作的数量要增加。例如,原来只要一次查询就可以获取的值现在需要两次查询。
不过相比接下来要讲的水平分表,这个复杂性就是小巫见大巫了。
水平分表
水平分表适合表行数特别大的表,有的公司要求单表行数超过 5000 万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。对于一些比较复杂的表,可能超过 1000 万就要分表了;而对于一些简单的表,即使存储数据超过 1 亿行,也可以不分表。但不管怎样,当看到表的数据量达到千万级别时,作为架构师就要警觉起来,因为这很可能是架构的性能瓶颈或者隐患。
水平分表相比垂直分表,会引入更多的复杂性,主要表现在下面几个方面:
1)路由
水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性。
常见的路由算法有:
范围路由:选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。以最常见的用户 ID 为例,路由算法可以按照 1000000 的范围大小进行分段,1 ~ 999999 放到数据库 1 的表中,1000000 ~ 1999999 放到数据库 2 的表中,以此类推。
范围路由设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多,增加维护复杂度;分段太大可能会导致单表依然存在性能问题,一般建议分段大小在 100 万至 2000 万之间,具体需要根据业务选取合适的分段大小。
范围路由的优点是可以随着数据的增加平滑地扩充新的表。例如,现在的用户是 100 万,如果增加到 1000 万,只需要增加新的表就可以了,原有的数据不需要动。
范围路由的一个比较隐含的缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。
Hash 路由:选取某个列(或者某几个列组合也可以)的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。同样以用户 ID 为例,假如我们一开始就规划了 10 个数据库表,路由算法可以简单地用 user_id % 10 的值来表示数据所属的数据库表编号,ID 为 985 的用户放到编号为 5 的子表中,ID 为 10086 的用户放到编号为 6 的字表中。
Hash 路由设计的复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。而用了 Hash 路由后,增加子表数量是非常麻烦的,所有数据都要重分布。
Hash 路由的优缺点和范围路由基本相反,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。
配置路由:配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户 ID 为例,我们新增一张 user_router 表,这个表包含 user_id 和 table_id 两列,根据 user_id 就可以查询对应的 table_id。
配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。
配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据),性能同样可能成为瓶颈,如果我们再次将路由表分库分表,则又面临一个死循环式的路由算法选择问题。
2)join 操作
水平分表后,数据分散在多个表中,如果需要与其他表进行 join 查询,需要在业务代码或者数据库中间件中进行多次 join 查询,然后将结果合并。
3)count() 操作
水平分表后,虽然物理上数据分散到多个表中,但某些业务逻辑上还是会将这些表当作一个表来处理。例如,获取记录总数用于分页或者展示,水平分表前用一个 count() 就能完成的操作,在分表后就没那么简单了。常见的处理方式有下面两种:
count() 相加:具体做法是在业务代码或者数据库中间件中对每个表进行 count() 操作,然后将结果相加。这种方式实现简单,缺点就是性能比较低。例如,水平分表后切分为 20 张表,则要进行 20 次 count(*) 操作,如果串行的话,可能需要几秒钟才能得到结果。
记录数表:具体做法是新建一张表,假如表名为“记录数表”,包含 table_name、row_count 两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。
这种方式获取表记录数的性能要大大优于 count() 相加的方式,因为只需要一次简单查询就可以获取数据。缺点是复杂度增加不少,对子表的操作要同步操作“记录数表”,如果有一个业务逻辑遗漏了,数据就会不一致;且针对“记录数表”的操作和针对子表的操作无法放在同一事务中进行处理,异常的情况下会出现操作子表成功了而操作记录数表失败,同样会导致数据不一致。
此外,记录数表的方式也增加了数据库的写压力,因为每次针对子表的 insert 和 delete 操作都要 update 记录数表,所以对于一些不要求记录数实时保持精确的业务,也可以通过后台定时更新记录数表。定时更新实际上就是“count() 相加”和“记录数表”的结合,即定时通过 count() 相加计算表的记录数,然后更新记录数表中的数据。
4)order by 操作
水平分表后,数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。
实现方式
和数据库读写分离类似,分库分表具体的实现方式也是“程序代码封装”和“中间件封装”,但实现会更复杂。读写分离实现时只要识别 SQL 操作是读操作还是写操作,通过简单的判断 SELECT、UPDATE、INSERT、DELETE 几个关键字就可以做到,而分库分表的实现除了要判断操作类型外,还要判断 SQL 中具体需要操作的表、操作函数(例如 count 函数)、order by、group by 操作等,然后再根据不同的操作进行不同的处理。例如 order by 操作,需要先从多个库查询到各个库的数据,然后再重新 order by 才能得到最终的结果。
高性能NoSQL
关系数据库经过几十年的发展后已经非常成熟,强大的 SQL 功能和 ACID 的属性,使得关系数据库广泛应用于各式各样的系统中,但这并不意味着关系数据库是完美的,关系数据库存在如下缺点。
1、关系数据库存储的是行记录,无法存储数据结构
以微博的关注关系为例,“我关注的人”是一个用户 ID 列表,使用关系数据库存储只能将列表拆成多行,然后再查询出来组装,无法直接存储一个列表。
2、关系数据库的 schema 扩展很不方便
关系数据库的表结构 schema 是强约束,操作不存在的列会报错,业务变化时扩充列也比较麻烦,需要执行 DDL(data definition language,如 CREATE、ALTER、DROP 等)语句修改,而且修改时可能会长时间锁表(例如,MySQL 可能将表锁住 1 个小时)。
3、关系数据库在大数据场景下 I/O 较高
如果对一些大量数据的表进行统计之类的运算,关系数据库的 I/O 会很高,因为即使只针对其中某一列进行运算,关系数据库也会将整行数据从存储设备读入内存。
4、关系数据库的全文搜索功能比较弱
关系数据库的全文搜索只能使用 like 进行整表扫描匹配,性能非常低,在互联网这种搜索复杂的场景下无法满足业务要求。
针对上述问题,分别诞生了不同的 NoSQL 解决方案,这些方案与关系数据库相比,在某些应用场景下表现更好。但世上没有免费的午餐,NoSQL 方案带来的优势,本质上是牺牲 ACID 中的某个或者某几个特性,因此不能盲目地迷信 NoSQL 是银弹,而应该将 NoSQL 作为 SQL 的一个有力补充,NoSQL != No SQL,而是 NoSQL = Not Only SQL。
常见的 NoSQL 方案分为 4 类。
- K-V 存储:解决关系数据库无法存储数据结构的问题,以 Redis 为代表。
- 文档数据库:解决关系数据库强 schema 约束的问题,以 MongoDB 为代表。
- 列式数据库:解决关系数据库大数据场景下的 I/O 问题,以 HBase 为代表。
- 全文搜索引擎:解决关系数据库的全文搜索性能问题,以 Elasticsearch 为代表。
这里介绍一下各种高性能 NoSQL 方案的典型特征和应用场景。
K-V 存储
K-V 存储的全称是 Key-Value 存储,其中 Key 是数据的标识,和关系数据库中的主键含义一样,Value 就是具体的数据。
Redis 是 K-V 存储的典型代表,它是一款开源(基于 BSD 许可)的高性能 K-V 缓存和存储系统。Redis 的 Value 是具体的数据结构,包括 string、hash、list、set、sorted set、bitmap 和 hyperloglog,所以常常被称为数据结构服务器。
以 List 数据结构为例,Redis 提供了下面这些典型的操作(更多请参考链接:http://redis.cn/commands.html#list):
- LPOP key 从队列的左边出队一个元素。
- LINDEX key index 获取一个元素,通过其索引列表。
- LLEN key 获得队列(List)的长度。
- RPOP key 从队列的右边出队一个元素。
以上这些功能,如果用关系数据库来实现,就会变得很复杂。例如,LPOP 操作是移除并返回 key 对应的 list 的第一个元素。如果用关系数据库来存储,为了达到同样目的,需要进行下面的操作:
1、每条数据除了数据编号(例如,行 ID),还要有位置编号,否则没有办法判断哪条数据是第一条。注意这里不能用行 ID 作为位置编号,因为我们会往列表头部插入数据。
2、查询出第一条数据。
3、删除第一条数据。
4、更新从第二条开始的所有数据的位置编号。
可以看出关系数据库的实现很麻烦,而且需要进行多次 SQL 操作,性能很低。
Redis 的缺点主要体现在并不支持完整的 ACID 事务,Redis 虽然提供事务功能,但 Redis 的事务和关系数据库的事务不可同日而语,Redis 的事务只能保证隔离性和一致性(I 和 C),无法保证原子性和持久性(A 和 D)。
虽然 Redis 并没有严格遵循 ACID 原则,但实际上大部分业务也不需要严格遵循 ACID 原则。以上面的微博关注操作为例,即使系统没有将 A 加入 B 的粉丝列表,其实业务影响也非常小,因此在设计方案时,需要根据业务特性和要求来确定是否可以用 Redis,而不能因为 Redis 不遵循 ACID 原则就直接放弃。
文档数据库
为了解决关系数据库 schema 带来的问题,文档数据库应运而生。文档数据库最大的特点就是 no-schema,可以存储和读取任意的数据。目前绝大部分文档数据库存储的数据格式是 JSON(或者 BSON),因为 JSON 数据是自描述的,无须在使用前定义字段,读取一个 JSON 中不存在的字段也不会导致 SQL 那样的语法错误。
文档数据库的 no-schema 特性,给业务开发带来了几个明显的优势。
1、新增字段简单
业务上增加新的字段,无须再像关系数据库一样要先执行 DDL 语句修改表结构,程序代码直接读写即可。
2、历史数据不会出错
对于历史数据,即使没有新增的字段,也不会导致错误,只会返回空值,此时代码进行兼容处理即可。
3、可以很容易存储复杂数据
JSON 是一种强大的描述语言,能够描述复杂的数据结构。例如,我们设计一个用户管理系统,用户的信息有 ID、姓名、性别、爱好、邮箱、地址、学历信息。其中爱好是列表(因为可以有多个爱好);地址是一个结构,包括省市区楼盘地址;学历包括学校、专业、入学毕业年份信息等。如果我们用关系数据库来存储,需要设计多张表,包括基本信息(列:ID、姓名、性别、邮箱)、爱好(列:ID、爱好)、地址(列:省、市、区、详细地址)、学历(列:入学时间、毕业时间、学校名称、专业),而使用文档数据库,一个 JSON 就可以全部描述。
文档数据库的这个特点,特别适合电商和游戏这类的业务场景。以电商为例,不同商品的属性差异很大。例如,冰箱的属性和笔记本电脑的属性差异非常大。
文档数据库 no-schema 的特性带来的这些优势也是有代价的,最主要的代价就是不支持事务。另外一个缺点就是无法实现关系数据库的 join 操作。
列式数据库
顾名思义,列式数据库就是按照列来存储数据的数据库,与之对应的传统关系数据库被称为“行式数据库”,因为关系数据库是按照行来存储数据的。关系数据库按照行式来存储数据,主要有以下几个优势:
1、业务同时读取多个列时效率高,因为这些列都是按行存储在一起的,一次磁盘操作就能够把一行数据中的各个列都读取到内存中。
2、能够一次性完成对一行中的多个列的写操作,保证了针对行数据写操作的原子性和一致性;否则如果采用列存储,可能会出现某次写操作,有的列成功了,有的列失败了,导致数据不一致。
行式存储的优势是在特定的业务场景下才能体现,如果不存在这样的业务场景,那么行式存储的优势也将不复存在,甚至成为劣势,典型的场景就是海量数据进行统计。
例如,计算某个城市体重超重的人员数据,实际上只需要读取每个人的体重这一列并进行统计即可,而行式存储即使最终只使用一列,也会将所有行数据都读取出来。如果单行用户信息有 1KB,其中体重只有 4 个字节,行式存储还是会将整行 1KB 数据全部读取到内存中,这是明显的浪费。而如果采用列式存储,每个用户只需要读取 4 字节的体重数据即可,I/O 将大大减少。
除了节省 I/O,列式存储还具备更高的存储压缩比,能够节省更多的存储空间。普通的行式数据库一般压缩率在 3:1 到 5:1 左右,而列式数据库的压缩率一般在 8:1 到 30:1 左右,因为单个列的数据相似度相比行来说更高,能够达到更高的压缩率。
同样,如果场景发生变化,列式存储的优势又会变成劣势。典型的场景是需要频繁地更新多个列。因为列式存储将不同列存储在磁盘上不连续的空间,导致更新多个列时磁盘是随机写操作;而行式存储时同一行多个列都存储在连续的空间,一次磁盘写操作就可以完成,列式存储的随机写效率要远远低于行式存储的写效率。此外,列式存储高压缩率在更新场景下也会成为劣势,因为更新时需要将存储数据解压后更新,然后再压缩,最后写入磁盘。
基于上述列式存储的优缺点,一般将列式存储应用在离线的大数据分析和统计场景中,因为这种场景主要是针对部分列单列进行操作,且数据写入后就无须再更新删除。
全文搜索引擎
传统的关系型数据库通过索引来达到快速查询的目的,但是在全文搜索的业务场景下,索引也无能为力,主要体现在:
1、全文搜索的条件可以随意排列组合,如果通过索引来满足,则索引的数量会非常多。
2、全文搜索的模糊匹配方式,索引无法满足,只能用 like 查询,而 like 查询是整表扫描,效率非常低。
全文搜索基本原理
全文搜索引擎的技术原理被称为“倒排索引”(Inverted index),也常被称为反向索引、置入档案或反向档案,是一种索引方法,其基本原理是建立单词到文档的索引。之所以被称为“倒排”索引,是和“正排“索引相对的,“正排索引”的基本原理是建立文档到单词的索引。我们通过一个简单的样例来说明这两种索引的差异。
全文搜索的使用方式
全文搜索引擎的索引对象是单词和文档,而关系数据库的索引对象是键和行,两者的术语差异很大,不能简单地等同起来。因此,为了让全文搜索引擎支持关系型数据的全文搜索,需要做一些转换操作,即将关系型数据转换为文档数据。
目前常用的转换方式是将关系型数据按照对象的形式转换为 JSON 文档,然后将 JSON 文档输入全文搜索引擎进行索引。
高性能缓存架构
虽然可以通过各种手段来提升存储系统的性能,但在某些复杂的业务场景下,单纯依靠存储系统的性能提升不够的,典型的场景有:
1、需要经过复杂运算后得出的数据,存储系统无能为力
例如,一个论坛需要在首页展示当前有多少用户同时在线,如果使用 MySQL 来存储当前用户状态,则每次获取这个总数都要“count(*)”大量数据,这样的操作无论怎么优化 MySQL,性能都不会太高。如果要实时展示用户同时在线数,则 MySQL 性能无法支撑。
2、读多写少的数据,存储系统有心无力
绝大部分在线业务都是读多写少。例如,微博、淘宝、微信这类互联网业务,读业务占了整体业务量的 90% 以上。以微博为例:一个明星发一条微博,可能几千万人来浏览。如果使用 MySQL 来存储微博,用户写微博只有一条 insert 语句,但每个用户浏览时都要 select 一次,即使有索引,几千万条 select 语句对 MySQL 数据库的压力也会非常大。
缓存就是为了弥补存储系统在这些复杂业务场景下的不足,其基本原理是将可能重复使用的数据放到内存中,一次生成、多次使用,避免每次使用都去访问存储系统。
缓存能够带来性能的大幅提升,以 Memcache 为例,单台 Memcache 服务器简单的 key-value 查询能够达到 TPS 50000 以上,其基本的架构是:

缓存虽然能够大大减轻存储系统的压力,但同时也给架构引入了更多复杂性。架构设计时如果没有针对缓存的复杂性进行处理,某些场景下甚至会导致整个系统崩溃。
缓存穿透
缓存穿透是指缓存没有发挥作用,业务系统虽然去缓存查询数据,但缓存中没有数据,业务系统需要再次去存储系统查询数据。通常情况下有两种情况:
1、存储数据不存在
第一种情况是被访问的数据确实不存在。一般情况下,如果存储系统中没有某个数据,则不会在缓存中存储相应的数据,这样就导致用户查询的时候,在缓存中找不到对应的数据,每次都要去存储系统中再查询一遍,然后返回数据不存在。缓存在这个场景中并没有起到分担存储系统访问压力的作用。
通常情况下,业务上读取不存在的数据的请求量并不会太大,但如果出现一些异常情况,例如被黑客攻击,故意大量访问某些读取不存在数据的业务,有可能会将存储系统拖垮。
这种情况的解决办法比较简单,如果查询存储系统的数据没有找到,则直接设置一个默认值(可以是空值,也可以是具体的值)存到缓存中,这样第二次读取缓存时就会获取到默认值,而不会继续访问存储系统。
2、缓存数据生成耗费大量时间或者资源
第二种情况是存储系统中存在数据,但生成缓存数据需要耗费较长时间或者耗费大量资源。如果刚好在业务访问的时候缓存失效了,那么也会出现缓存没有发挥作用,访问压力全部集中在存储系统上的情况。
典型的就是电商的商品分页,假设我们在某个电商平台上选择“手机”这个类别查看,由于数据巨大,不能把所有数据都缓存起来,只能按照分页来进行缓存,由于难以预测用户到底会访问哪些分页,因此业务上最简单的就是每次点击分页的时候按分页计算和生成缓存。通常情况下这样实现是基本满足要求的,但是如果被竞争对手用爬虫来遍历的时候,系统性能就可能出现问题。
具体的场景有:
- 分页缓存的有效期设置为 1 天,因为设置太长时间的话,缓存不能反应真实的数据。
- 通常情况下,用户不会从第 1 页到最后 1 页全部看完,一般用户访问集中在前 10 页,因此第 10 页以后的缓存过期失效的可能性很大。
- 竞争对手每周来爬取数据,爬虫会将所有分类的所有数据全部遍历,从第 1 页到最后 1 页全部都会读取,此时很多分页缓存可能都失效了。
- 由于很多分页都没有缓存数据,从数据库中生成缓存数据又非常耗费性能(order by limit 操作),因此爬虫会将整个数据库全部拖慢。
这种情况并没有太好的解决方案,因为爬虫会遍历所有的数据,而且什么时候来爬取也是不确定的,可能是每天都来,也可能是每周,也可能是一个月来一次,也不可能为了应对爬虫而将所有数据永久缓存。通常的应对方案要么就是识别爬虫然后禁止访问,但这可能会影响 SEO 和推广;要么就是做好监控,发现问题后及时处理,因为爬虫不是攻击,不会进行暴力破坏,对系统的影响是逐步的,监控发现问题后有时间进行处理。
缓存雪崩
缓存雪崩是指当缓存失效(过期)后引起系统性能急剧下降的情况。当缓存过期被清除后,业务系统需要重新生成缓存,因此需要再次访问存储系统,再次进行运算,这个处理步骤耗时几十毫秒甚至上百毫秒。而对于一个高并发的业务系统来说,几百毫秒内可能会接到几百上千个请求。由于旧的缓存已经被清除,新的缓存还未生成,并且处理这些请求的线程都不知道另外有一个线程正在生成缓存,因此所有的请求都会去重新生成缓存,都会去访问存储系统,从而对存储系统造成巨大的性能压力。这些压力又会拖慢整个系统,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。
缓存雪崩的常见解决方法有两种:更新锁机制和后台更新机制。
1、更新锁
对缓存更新操作进行加锁保护,保证只有一个线程能够进行缓存更新,未能获取更新锁的线程要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
对于采用分布式集群的业务系统,由于存在几十上百台服务器,即使单台服务器只有一个线程更新缓存,但几十上百台服务器一起算下来也会有几十上百个线程同时来更新缓存,同样存在雪崩的问题。因此分布式集群的业务系统要实现更新锁机制,需要用到分布式锁,如 ZooKeeper。
2、后台更新
由后台线程来更新缓存,而不是由业务线程来更新缓存,缓存本身的有效期设置为永久,后台线程定时更新缓存。
后台定时机制需要考虑一种特殊的场景,当缓存系统内存不够时,会“踢掉”一些缓存数据,从缓存被“踢掉”到下一次定时更新缓存的这段时间内,业务线程读取缓存返回空值,而业务线程本身又不会去更新缓存,因此业务上看到的现象就是数据丢了。解决的方式有两种:
1)后台线程除了定时更新缓存,还要频繁地去读取缓存(例如,1 秒或者 100 毫秒读取一次),如果发现缓存被“踢了”就立刻更新缓存,这种方式实现简单,但读取时间间隔不能设置太长,因为如果缓存被踢了,缓存读取间隔时间又太长,这段时间内业务访问都拿不到真正的数据而是一个空的缓存值,用户体验一般。
2)业务线程发现缓存失效后,通过消息队列发送一条消息通知后台线程更新缓存。可能会出现多个业务线程都发送了缓存更新消息,但其实对后台线程没有影响,后台线程收到消息后更新缓存前可以判断缓存是否存在,存在就不执行更新操作。这种方式实现依赖消息队列,复杂度会高一些,但缓存更新更及时,用户体验更好
后台更新既适应单机多线程的场景,也适合分布式集群的场景,相比更新锁机制要简单一些。
后台更新机制还适合业务刚上线的时候进行缓存预热。缓存预热指系统上线后,将相关的缓存数据直接加载到缓存系统,而不是等待用户访问才来触发缓存加载。
缓存热点
虽然缓存系统本身的性能比较高,但对于一些特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力也很大。例如,某明星微博发布“我们”来宣告恋爱了,短时间内上千万的用户都会来围观。
缓存热点的解决方案就是复制多份缓存副本,将请求分散到多个缓存服务器上,减轻缓存热点导致的单台缓存服务器压力。以微博为例,对于粉丝数超过 100 万的明星,每条微博都可以生成 100 份缓存,缓存的数据是一样的,通过在缓存的 key 里面加上编号进行区分,每次读缓存时都随机读取其中某份缓存。
缓存副本设计有一个细节需要注意,就是不同的缓存副本不要设置统一的过期时间,否则就会出现所有缓存副本同时生成同时失效的情况,从而引发缓存雪崩效应。正确的做法是设定一个过期时间范围,不同的缓存副本的过期时间是指定范围内的随机值。
实现方式
由于缓存的各种访问策略和存储的访问策略是相关的,因此上面的各种缓存设计方案通常情况下都是集成在存储访问方案中,可以采用“程序代码实现”的中间层方式,也可以采用独立的中间件来实现。
单服务器高性能模式:PPC与TPC
高性能是每个程序员的追求,无论我们是做一个系统还是写一行代码,都希望能够达到高性能的效果,而高性能又是最复杂的一环,磁盘、操作系统、CPU、内存、缓存、网络、编程语言、架构等,每个都有可能影响系统达到高性能,一行不恰当的 debug 日志,就可能将服务器的性能从 TPS 30000 降低到 8000;一个 tcp_nodelay 参数,就可能将响应时间从 2 毫秒延长到 40 毫秒。因此,要做到高性能计算是一件很复杂很有挑战的事情,软件系统开发过程中的不同阶段都关系着高性能最终是否能够实现。
站在架构师的角度,当然需要特别关注高性能架构的设计。高性能架构设计主要集中在两方面:
- 尽量提升单服务器的性能,将单服务器的性能发挥到极致。
- 如果单服务器无法支撑性能,设计服务器集群方案。
除了以上两点,最终系统能否实现高性能,还和具体的实现及编码相关。但架构设计是高性能的基础,如果架构设计没有做到高性能,则后面的具体实现和编码能提升的空间是有限的。形象地说,架构设计决定了系统性能的上限,实现细节决定了系统性能的下限。
单服务器高性能的关键之一就是服务器采取的并发模型,并发模型有如下两个关键设计点:
- 服务器如何管理连接。
- 服务器如何处理请求。
以上两个设计点最终都和操作系统的 I/O 模型及进程模型相关。
- I/O 模型:阻塞、非阻塞、同步、异步。
- 进程模型:单进程、多进程、多线程。
在下面详细介绍并发模型时会用到上面这些基础的知识点,建议先检测一下对这些基础知识的掌握情况,更多内容可以参考《UNIX 网络编程》三卷本。这里先来看看单服务器高性能模式:PPC 与 TPC。
PPC
PPC 是 Process Per Connection 的缩写,其含义是指每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的 UNIX 网络服务器所采用的模型。基本的流程图是:

- 父进程接受连接(图中 accept)。
- 父进程“fork”子进程(图中 fork)。
- 子进程处理连接的读写请求(图中子进程 read、业务处理、write)。
- 子进程关闭连接(图中子进程中的 close)。
注意,图中有一个小细节,父进程“fork”子进程后,直接调用了 close,看起来好像是关闭了连接,其实只是将连接的文件描述符引用计数减一,真正的关闭连接是等子进程也调用 close 后,连接对应的文件描述符引用计数变为 0 后,操作系统才会真正关闭连接,更多细节请参考《UNIX 网络编程:卷一》。
PPC 模式实现简单,比较适合服务器的连接数没那么多的情况,例如数据库服务器。对于普通的业务服务器,在互联网兴起之前,由于服务器的访问量和并发量并没有那么大,这种模式其实运作得也挺好,世界上第一个 web 服务器 CERN httpd 就采用了这种模式(具体可以参考https://en.wikipedia.org/wiki/CERN_httpd)。互联网兴起后,服务器的并发和访问量从几十剧增到成千上万,这种模式的弊端就凸显出来了,主要体现在这几个方面:
fork 代价高:站在操作系统的角度,创建一个进程的代价是很高的,需要分配很多内核资源,需要将内存映像从父进程复制到子进程。即使现在的操作系统在复制内存映像时用到了 Copy on Write(写时复制)技术,总体来说创建进程的代价还是很大的。父子进程通信复杂:父进程“fork”子进程时,文件描述符可以通过内存映像复制从父进程传到子进程,但“fork”完成后,父子进程通信就比较麻烦了,需要采用 IPC(Interprocess Communication)之类的进程通信方案。例如,子进程需要在 close 之前告诉父进程自己处理了多少个请求以支撑父进程进行全局的统计,那么子进程和父进程必须采用 IPC 方案来传递信息。支持的并发连接数量有限:如果每个连接存活时间比较长,而且新的连接又源源不断的进来,则进程数量会越来越多,操作系统进程调度和切换的频率也越来越高,系统的压力也会越来越大。因此,一般情况下,PPC 方案能处理的并发连接数量最大也就几百。
prefork
PPC 模式中,当连接进来时才 fork 新进程来处理连接请求,由于 fork 进程代价高,用户访问时可能感觉比较慢,prefork 模式的出现就是为了解决这个问题。
顾名思义,prefork 就是提前创建进程(pre-fork)。系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去 fork 进程的操作,让用户访问更快、体验更好。prefork 的基本示意图是:

prefork 的实现关键就是多个子进程都 accept 同一个 socket,当有新的连接进入时,操作系统保证只有一个进程能最后 accept 成功。但这里也存在一个小小的问题:“惊群”现象,就是指虽然只有一个子进程能 accept 成功,但所有阻塞在 accept 上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换了。幸运的是,操作系统可以解决这个问题,例如 Linux 2.6 版本后内核已经解决了 accept 惊群问题。
prefork 模式和 PPC 一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。Apache 服务器提供了 MPM prefork 模式,推荐在需要可靠性或者与旧软件兼容的站点时采用这种模式,默认情况下最大支持 256 个并发连接。
TPC
TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。
TPC 的基本流程是:

1、父进程接受连接(图中 accept)。
2、父进程创建子线程(图中 pthread)。
3、子线程处理连接的读写请求(图中子线程 read、业务处理、write)。
4、子线程关闭连接(图中子线程中的 close)。
注意,和 PPC 相比,主进程不用“close”连接了。原因是在于子线程是共享主进程的进程空间的,连接的文件描述符并没有被复制,因此只需要一次 close 即可。
TPC 虽然解决了 fork 代价高和进程通信复杂的问题,但是也引入了新的问题,具体表现在:
1、创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题。
2、无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
3、多线程会出现互相影响的情况,某个线程出现异常时,可能导致整个进程退出(例如内存越界)。
除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。因此,TPC 方案本质上和 PPC 方案基本类似,在并发几百连接的场景下,反而更多地是采用 PPC 的方案,因为 PPC 方案不会有死锁的风险,也不会多进程互相影响,稳定性更高。
prethread
TPC 模式中,当连接进来时才创建新的线程来处理连接请求,虽然创建线程比创建进程要更加轻量级,但还是有一定的代价,而 prethread 模式就是为了解决这个问题。
和 prefork 类似,prethread 模式会预先创建线程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。
由于多线程之间数据共享和通信比较方便,因此实际上 prethread 的实现方式相比 prefork 要灵活一些,常见的实现方式有下面几种:
1、主进程 accept,然后将连接交给某个线程处理。
2、子线程都尝试去 accept,最终只有一个线程 accept 成功,方案的基本示意图如下:

Apache 服务器的 MPM worker 模式本质上就是一种 prethread 方案,但稍微做了改进。Apache 服务器会首先创建多个进程,每个进程里面再创建多个线程,这样做主要是为了考虑稳定性,即:即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续提供服务,不会导致整个服务器全部挂掉。
prethread 理论上可以比 prefork 支持更多的并发连接,Apache 服务器 MPM worker 模式默认支持 16 × 25 = 400 个并发处理线程。
单服务器高性能模式:Reactor与Proactor
上一节介绍了单服务器高性能的 PPC 和 TPC 模式,它们的优点是实现简单,缺点是都无法支撑高并发的场景,尤其是互联网发展到现在,各种海量用户业务的出现,PPC 和 TPC 完全无能为力。这一节介绍可以应对高并发场景的单服务器高性能架构模式:Reactor 和 Proactor。
Reactor
PPC 模式最主要的问题就是每个连接都要创建进程(为了描述简洁,这里只以 PPC 和进程为例,实际上换成 TPC 和线程,原理是一样的),连接结束后进程就销毁了,这样做其实是很大的浪费。为了解决这个问题,一个自然而然的想法就是资源复用,即不再单独为每个连接创建进程,而是创建一个进程池,将连接分配给进程,一个进程可以处理多个连接的业务。
引入资源池的处理方式后,会引出一个新的问题:进程如何才能高效地处理多个连接的业务?当一个连接一个进程时,进程可以采用“read -> 业务处理 -> write”的处理流程,如果当前连接没有数据可以读,则进程就阻塞在 read 操作上。这种阻塞的方式在一个连接一个进程的场景下没有问题,但如果一个进程处理多个连接,进程阻塞在某个连接的 read 操作上,此时即使其他连接有数据可读,进程也无法去处理,很显然这样是无法做到高性能的。
解决这个问题的最简单的方式是将 read 操作改为非阻塞,然后进程不断地轮询多个连接。这种方式能够解决阻塞的问题,但解决的方式并不优雅。首先,轮询是要消耗 CPU 的;其次,如果一个进程处理几千上万的连接,则轮询的效率是很低的。
为了能够更好地解决上述问题,很容易可以想到,只有当连接上有数据的时候进程才去处理,这就是 I/O 多路复用技术的来源。
I/O 多路复用技术归纳起来有两个关键实现点:
1、当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等。
2、当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。
I/O 多路复用结合线程池,完美地解决了 PPC 和 TPC 的问题,而且“大神们”给它取了一个很牛的名字:Reactor,中文是“反应堆”。联想到“核反应堆”,听起来就很吓人,实际上这里的“反应”不是聚变、裂变反应的意思,而是“事件反应”的意思,可以通俗地理解为“来了一个事件我就有相应的反应”,这里的“我”就是 Reactor,具体的反应就是我们写的代码,Reactor 会根据事件类型来调用相应的代码进行处理。Reactor 模式也叫 Dispatcher 模式(在很多开源的系统里面会看到这个名称的类,其实就是实现 Reactor 模式的),更加贴近模式本身的含义,即 I/O 多路复用统一监听事件,收到事件后分配(Dispatch)给某个进程。
Reactor 模式的核心组成部分包括 Reactor 和处理资源池(进程池或线程池),其中 Reactor 负责监听和分配事件,处理资源池负责处理事件。初看 Reactor 的实现是比较简单的,但实际上结合不同的业务场景,Reactor 模式的具体实现方案灵活多变,主要体现在:
1、Reactor 的数量可以变化:可以是一个 Reactor,也可以是多个 Reactor。
2、资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程(线程类似)。
将上面两个因素排列组合一下,理论上可以有 4 种选择,但由于“多 Reactor 单进程”实现方案相比“单 Reactor 单进程”方案,既复杂又没有性能优势,因此“多 Reactor 单进程”方案仅仅是一个理论上的方案,实际没有应用。
最终 Reactor 模式有这三种典型的实现方案:
1、单 Reactor 单进程 / 线程。
2、单 Reactor 多线程。
3、多 Reactor 多进程 / 线程。
以上方案具体选择进程还是线程,更多地是和编程语言及平台相关。例如,Java 语言一般使用线程(例如,Netty),C 语言使用进程和线程都可以。例如,Nginx 使用进程,Memcache 使用线程。
单Reactor单进程/线程
单 Reactor 单进程 / 线程的方案示意图如下(以进程为例):

注意,select、accept、read、send 是标准的网络编程 API,dispatch 和“业务处理”是需要完成的操作,其他方案示意图类似。
详细说明一下这个方案:
1、Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行分发。
2、如果是连接建立的事件,则由 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件。
3、如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应。
4、Handler 会完成 read-> 业务处理 ->send 的完整业务流程。
单 Reactor 单进程的模式优点就是很简单,没有进程间通信,没有进程竞争,全部都在同一个进程内完成。但其缺点也是非常明显,具体表现有:
1、只有一个进程,无法发挥多核 CPU 的性能;只能采取部署多个系统来利用多核 CPU,但这样会带来运维复杂度,本来只要维护一个系统,用这种方式需要在一台机器上维护多套系统。
2、Handler 在处理某个连接上的业务时,整个进程无法处理其他连接的事件,很容易导致性能瓶颈。
因此,单 Reactor 单进程的方案在实践中应用场景不多,只适用于业务处理非常快速的场景,目前比较著名的开源软件中使用单 Reactor 单进程的是 Redis。
需要注意的是,C 语言编写系统的一般使用单 Reactor 单进程,因为没有必要在进程中再创建线程;而 Java 语言编写的一般使用单 Reactor 单线程,因为 Java 虚拟机是一个进程,虚拟机中有很多线程,业务线程只是其中的一个线程而已。
单Reactor多进程
为了克服单 Reactor 单进程 / 线程方案的缺点,引入多进程 / 多线程是显而易见的,这就产生了第 2 个方案:单 Reactor 多线程。
单 Reactor 多线程方案示意图是:

方案介绍:
1、主线程中,Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行分发。
2、如果是连接建立的事件,则由 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件。
3、如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(第 2 步中创建的 Handler)来进行响应。
4、Handler 只负责响应事件,不进行业务处理;Handler 通过 read 读取到数据后,会发给 Processor 进行业务处理。
5、Processor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主进程的 Handler 处理;Handler 收到响应后通过 send 将响应结果返回给 client。
单 Reator 多线程方案能够充分利用多核多 CPU 的处理能力,但同时也存在下面的问题:
1、多线程数据共享和访问比较复杂。例如,子线程完成业务处理后,要把结果传递给主线程的 Reactor 进行发送,这里涉及共享数据的互斥和保护机制。以 Java 的 NIO 为例,Selector 是线程安全的,但是通过 Selector.selectKeys() 返回的键的集合是非线程安全的,对 selected keys 的处理必须单线程处理或者采取同步措施进行保护。
2、Reactor 承担所有事件的监听和响应,只在主线程中运行,瞬间高并发时会成为性能瓶颈。
这里只列出了“单 Reactor 多线程”方案,没有列出“单 Reactor 多进程”方案,主要原因在于如果采用多进程,子进程完成业务处理后,将结果返回给父进程,并通知父进程发送给哪个 client,这是很麻烦的事情。因为父进程只是通过 Reactor 监听各个连接上的事件然后进行分配,子进程与父进程通信时并不是一个连接。如果要将父进程和子进程之间的通信模拟为一个连接,并加入 Reactor 进行监听,则是比较复杂的。而采用多线程时,因为多线程是共享数据的,因此线程间通信是非常方便的。虽然要额外考虑线程间共享数据时的同步问题,但这个复杂度比进程间通信的复杂度要低很多。
多Reactor多进程/线程
为了解决单 Reactor 多线程的问题,最直观的方法就是将单 Reactor 改为多 Reactor,这就产生了第 3 个方案:多 Reactor 多进程 / 线程。
多 Reactor 多进程 / 线程方案示意图是(以进程为例):

方案详细说明如下:
1、父进程中 mainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 接收,将新的连接分配给某个子进程。
2、子进程的 subReactor 将 mainReactor 分配的连接加入连接队列进行监听,并创建一个 Handler 用于处理连接的各种事件。
4、当有新的事件发生时,subReactor 会调用连接对应的 Handler(即第 2 步中创建的 Handler)来进行响应。
5、Handler 完成 read→业务处理→send 的完整业务流程。
多 Reactor 多进程 / 线程的方案看起来比单 Reactor 多线程要复杂,但实际实现时反而更加简单,主要原因是:
1、父进程和子进程的职责非常明确,父进程只负责接收新连接,子进程负责完成后续的业务处理。
2、父进程和子进程的交互很简单,父进程只需要把新连接传给子进程,子进程无须返回数据。
3、子进程之间是互相独立的,无须同步共享之类的处理(这里仅限于网络模型相关的 select、read、send 等无须同步共享,“业务处理”还是有可能需要同步共享的)。
目前著名的开源系统 Nginx 采用的是多 Reactor 多进程,采用多 Reactor 多线程的实现有 Memcache 和 Netty。
Nginx 采用的是多 Reactor 多进程的模式,但方案与标准的多 Reactor 多进程有差异。具体差异表现为主进程中仅仅创建了监听端口,并没有创建 mainReactor 来“accept”连接,而是由子进程的 Reactor 来“accept”连接,通过锁来控制一次只有一个子进程进行“accept”,子进程“accept”新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程,更多细节请查阅相关资料或阅读 Nginx 源码。
Proactor
Reactor 是非阻塞同步网络模型,因为真正的 read 和 send 操作都需要用户进程同步操作。这里的“同步”指用户进程在执行 read 和 send 这类 I/O 操作的时候是同步的,如果把 I/O 操作改为异步就能够进一步提升性能,这就是异步网络模型 Proactor。
Proactor 中文翻译为“前摄器”比较难理解,与其类似的单词是 proactive,含义为“主动的”,因此我们照猫画虎翻译为“主动器”反而更好理解。Reactor 可以理解为“来了事件我通知你,你来处理”,而 Proactor 可以理解为“来了事件我来处理,处理完了我通知你”。这里的“我”就是操作系统内核,“事件”就是有新连接、有数据可读、有数据可写的这些 I/O 事件,“你”就是我们的程序代码。
Proactor 模型示意图是:

详细介绍一下 Proactor 方案:
1、Proactor Initiator 负责创建 Proactor 和 Handler,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核。
2、Asynchronous Operation Processor 负责处理注册请求,并完成 I/O 操作。
3、Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor。
4、Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理。
5、Handler 完成业务处理,Handler 也可以注册新的 Handler 到内核进程。
理论上 Proactor 比 Reactor 效率要高一些,异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠,但要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下的 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 Reactor 模式为主。所以即使 Boost.Asio 号称实现了 Proactor 模型,其实它在 Windows 下采用 IOCP,而在 Linux 下是用 Reactor 模式(采用 epoll)模拟出来的异步模型。
高性能负载均衡:分类及架构
单服务器无论如何优化,无论采用多好的硬件,总会有一个性能天花板,当单服务器的性能无法满足业务需求时,就需要设计高性能集群来提升系统整体的处理性能。
高性能集群的本质很简单,通过增加更多的服务器来提升系统整体的计算能力。由于计算本身存在一个特点:同样的输入数据和逻辑,无论在哪台服务器上执行,都应该得到相同的输出。因此高性能集群设计的复杂度主要体现在任务分配这部分,需要设计合理的任务分配策略,将计算任务分配到多台服务器上执行。
高性能集群的复杂性主要体现在需要增加一个任务分配器,以及为任务选择一个合适的任务分配算法。对于任务分配器,现在更流行的通用叫法是“负载均衡器”。但这个名称有一定的误导性,会让人潜意识里认为任务分配的目的是要保持各个计算单元的负载达到均衡状态。而实际上任务分配并不只是考虑计算单元的负载均衡,不同的任务分配算法目标是不一样的,有的基于负载考虑,有的基于性能(吞吐量、响应时间)考虑,有的基于业务考虑。考虑到“负载均衡”已经成为了事实上的标准术语,这里我也用“负载均衡”来代替“任务分配”,但请时刻记住,负载均衡不只是为了计算单元的负载达到均衡状态。
负载均衡分类
常见的负载均衡系统包括 3 种:DNS 负载均衡、硬件负载均衡和软件负载均衡。
DNS 负载均衡
DNS 是最简单也是最常见的负载均衡方式,一般用来实现地理级别的均衡。例如,北方的用户访问北京的机房,南方的用户访问深圳的机房。DNS 负载均衡的本质是 DNS 解析同一个域名可以返回不同的 IP 地址。例如,同样是 www.baidu.com,北方用户解析后获取的地址是 61.135.165.224(这是北京机房的 IP),南方用户解析后获取的地址是 14.215.177.38(这是深圳机房的 IP)。
下面是 DNS 负载均衡的简单示意图:

DNS 负载均衡实现简单、成本低,但也存在粒度太粗、负载均衡算法少等缺点。仔细分析一下优缺点,其优点有:
1、简单、成本低:负载均衡工作交给 DNS 服务器处理,无须自己开发或者维护负载均衡设备。
2、就近访问,提升访问速度:DNS 解析时可以根据请求来源 IP,解析成距离用户最近的服务器地址,可以加快访问速度,改善性能。
缺点有:
1、更新不及时:DNS 缓存的时间比较长,修改 DNS 配置后,由于缓存的原因,还是有很多用户会继续访问修改前的 IP,这样的访问会失败,达不到负载均衡的目的,并且也影响用户正常使用业务。
2、扩展性差:DNS 负载均衡的控制权在域名商那里,无法根据业务特点针对其做更多的定制化功能和扩展特性。
3、分配策略比较简单:DNS 负载均衡支持的算法少;不能区分服务器的差异(不能根据系统与服务的状态来判断负载);也无法感知后端服务器的状态。
针对 DNS 负载均衡的一些缺点,对于时延和故障敏感的业务,有一些公司自己实现了 HTTP-DNS 的功能,即使用 HTTP 协议实现一个私有的 DNS 系统。这样的方案和通用的 DNS 优缺点正好相反。
硬件负载均衡
硬件负载均衡是通过单独的硬件设备来实现负载均衡功能,这类设备和路由器、交换机类似,可以理解为一个用于负载均衡的基础网络设备。目前业界典型的硬件负载均衡设备有两款:F5 和 A10。这类设备性能强劲、功能强大,但价格都不便宜,一般只有“土豪”公司才会考虑使用此类设备。普通业务量级的公司一是负担不起,二是业务量没那么大,用这些设备也是浪费。
硬件负载均衡的优点是:
1、功能强大:全面支持各层级的负载均衡,支持全面的负载均衡算法,支持全局负载均衡。
2、性能强大:对比一下,软件负载均衡支持到 10 万级并发已经很厉害了,硬件负载均衡可以支持 100 万以上的并发。
3、稳定性高:商用硬件负载均衡,经过了良好的严格测试,经过大规模使用,稳定性高。
4、支持安全防护:硬件均衡设备除具备负载均衡功能外,还具备防火墙、防 DDoS 攻击等安全功能。
硬件负载均衡的缺点是:
1、价格昂贵:最普通的一台 F5 就是一台“马 6”,好一点的就是“Q7”了。
2、扩展能力差:硬件设备,可以根据业务进行配置,但无法进行扩展和定制。
软件负载均衡
软件负载均衡通过负载均衡软件来实现负载均衡功能,常见的有 Nginx 和 LVS,其中 Nginx 是软件的 7 层负载均衡,LVS 是 Linux 内核的 4 层负载均衡。4 层和 7 层的区别就在于协议和灵活性,Nginx 支持 HTTP、E-mail 协议;而 LVS 是 4 层负载均衡,和协议无关,几乎所有应用都可以做,例如,聊天、数据库等。
软件和硬件的最主要区别就在于性能,硬件负载均衡性能远远高于软件负载均衡性能。Nginx 的性能是万级,一般的 Linux 服务器上装一个 Nginx 大概能到 5 万 / 秒;LVS 的性能是十万级,据说可达到 80 万 / 秒;而 F5 性能是百万级,从 200 万 / 秒到 800 万 / 秒都有(数据来源网络,仅供参考,如需采用请根据实际业务场景进行性能测试)。当然,软件负载均衡的最大优势是便宜,一台普通的 Linux 服务器批发价大概就是 1 万元左右,相比 F5 的价格,那就是自行车和宝马的区别了。
除了使用开源的系统进行负载均衡,如果业务比较特殊,也可能基于开源系统进行定制(例如,Nginx 插件),甚至进行自研。
下面是 Nginx 的负载均衡架构示意图:

软件负载均衡的优点:
1、简单:无论是部署还是维护都比较简单。
2、便宜:只要买个 Linux 服务器,装上软件即可。
3、灵活:4 层和 7 层负载均衡可以根据业务进行选择;也可以根据业务进行比较方便的扩展,例如,可以通过 Nginx 的插件来实现业务的定制化功能。
其实下面的缺点都是和硬件负载均衡相比的,并不是说软件负载均衡没法用。
1、性能一般:一个 Nginx 大约能支撑 5 万并发。
2、功能没有硬件负载均衡那么强大。
3、一般不具备防火墙和防 DDoS 攻击等安全功能。
负载均衡典型架构
前面介绍了 3 种常见的负载均衡机制:DNS 负载均衡、硬件负载均衡、软件负载均衡,每种方式都有一些优缺点,但并不意味着在实际应用中只能基于它们的优缺点进行非此即彼的选择,反而是基于它们的优缺点进行组合使用。具体来说,组合的基本原则为:DNS 负载均衡用于实现地理级别的负载均衡;硬件负载均衡用于实现集群级别的负载均衡;软件负载均衡用于实现机器级别的负载均衡。
以一个假想的实例来说明一下这种组合方式,如下图所示。

整个系统的负载均衡分为三层。
1、地理级别负载均衡:www.xxx.com 部署在北京、广州、上海三个机房,当用户访问时,DNS 会根据用户的地理位置来决定返回哪个机房的 IP,图中返回了广州机房的 IP 地址,这样用户就访问到广州机房了。
2、集群级别负载均衡:广州机房的负载均衡用的是 F5 设备,F5 收到用户请求后,进行集群级别的负载均衡,将用户请求发给 3 个本地集群中的一个,我们假设 F5 将用户请求发给了“广州集群 2”。
3、机器级别的负载均衡:广州集群 2 的负载均衡用的是 Nginx,Nginx 收到用户请求后,将用户请求发送给集群里面的某台服务器,服务器处理用户的业务请求并返回业务响应。
需要注意的是,上图只是一个示例,一般在大型业务场景下才会这样用,如果业务量没这么大,则没有必要严格照搬这套架构。例如,一个大学的论坛,完全可以不需要 DNS 负载均衡,也不需要 F5 设备,只需要用 Nginx 作为一个简单的负载均衡就足够了。
高性能负载均衡:算法
负载均衡算法数量较多,而且可以根据一些业务特性进行定制开发,抛开细节上的差异,根据算法期望达到的目的,大体上可以分为下面几类。
1、任务平分类:负载均衡系统将收到的任务平均分配给服务器进行处理,这里的“平均”可以是绝对数量的平均,也可以是比例或者权重上的平均。
2、负载均衡类:负载均衡系统根据服务器的负载来进行分配,这里的负载并不一定是通常意义上我们说的“CPU 负载”,而是系统当前的压力,可以用 CPU 负载来衡量,也可以用连接数、I/O 使用率、网卡吞吐量等来衡量系统的压力。
3、性能最优类:负载均衡系统根据服务器的响应时间来进行任务分配,优先将新任务分配给响应最快的服务器。
4、Hash 类:负载均衡系统根据任务中的某些关键信息进行 Hash 运算,将相同 Hash 值的请求分配到同一台服务器上。常见的有源地址 Hash、目标地址 Hash、session id hash、用户 ID Hash 等。
接下来介绍一下负载均衡算法以及它们的优缺点。
轮询
负载均衡系统收到请求后,按照顺序轮流分配到服务器上。
轮询是最简单的一个策略,无须关注服务器本身的状态,例如:
1、某个服务器当前因为触发了程序 bug 进入了死循环导致 CPU 负载很高,负载均衡系统是不感知的,还是会继续将请求源源不断地发送给它。
2、集群中有新的机器是 32 核的,老的机器是 16 核的,负载均衡系统也是不关注的,新老机器分配的任务数是一样的。
需要注意的是负载均衡系统无须关注“服务器本身状态”,这里的关键词是“本身”。也就是说,只要服务器在运行,运行状态是不关注的。但如果服务器直接宕机了,或者服务器和负载均衡系统断连了,这时负载均衡系统是能够感知的,也需要做出相应的处理。例如,将服务器从可分配服务器列表中删除,否则就会出现服务器都宕机了,任务还不断地分配给它,这明显是不合理的。
总而言之,“简单”是轮询算法的优点,也是它的缺点。
加权轮询
负载均衡系统根据服务器权重进行任务分配,这里的权重一般是根据硬件配置进行静态配置的,采用动态的方式计算会更加契合业务,但复杂度也会更高。
加权轮询是轮询的一种特殊形式,其主要目的就是为了解决不同服务器处理能力有差异的问题。例如,集群中有新的机器是 32 核的,老的机器是 16 核的,那么理论上我们可以假设新机器的处理能力是老机器的 2 倍,负载均衡系统就可以按照 2:1 的比例分配更多的任务给新机器,从而充分利用新机器的性能。
加权轮询解决了轮询算法中无法根据服务器的配置差异进行任务分配的问题,但同样存在无法根据服务器的状态差异进行任务分配的问题。
负载最低优先
负载均衡系统将任务分配给当前负载最低的服务器,这里的负载根据不同的任务类型和业务场景,可以用不同的指标来衡量。例如:
1、LVS 这种 4 层网络负载均衡设备,可以以“连接数”来判断服务器的状态,服务器连接数越大,表明服务器压力越大。
2、Nginx 这种 7 层网络负载系统,可以以“HTTP 请求数”来判断服务器状态(Nginx 内置的负载均衡算法不支持这种方式,需要进行扩展)。
3、如果自己开发负载均衡系统,可以根据业务特点来选择指标衡量系统压力。如果是 CPU 密集型,可以以“CPU 负载”来衡量系统压力;如果是 I/O 密集型,可以以“I/O 负载”来衡量系统压力。
负载最低优先的算法解决了轮询算法中无法感知服务器状态的问题,由此带来的代价是复杂度要增加很多。例如:
1、最少连接数优先的算法要求负载均衡系统统计每个服务器当前建立的连接,其应用场景仅限于负载均衡接收的任何连接请求都会转发给服务器进行处理,否则如果负载均衡系统和服务器之间是固定的连接池方式,就不适合采取这种算法。例如,LVS 可以采取这种算法进行负载均衡,而一个通过连接池的方式连接 MySQL 集群的负载均衡系统就不适合采取这种算法进行负载均衡。
2、CPU 负载最低优先的算法要求负载均衡系统以某种方式收集每个服务器的 CPU 负载,而且要确定是以 1 分钟的负载为标准,还是以 15 分钟的负载为标准,不存在 1 分钟肯定比 15 分钟要好或者差。不同业务最优的时间间隔是不一样的,时间间隔太短容易造成频繁波动,时间间隔太长又可能造成峰值来临时响应缓慢。
负载最低优先算法基本上能够比较完美地解决轮询算法的缺点,因为采用这种算法后,负载均衡系统需要感知服务器当前的运行状态。当然,其代价是复杂度大幅上升。通俗来讲,轮询可能是 5 行代码就能实现的算法,而负载最低优先算法可能要 1000 行才能实现,甚至需要负载均衡系统和服务器都要开发代码。负载最低优先算法如果本身没有设计好,或者不适合业务的运行特点,算法本身就可能成为性能的瓶颈,或者引发很多莫名其妙的问题。所以负载最低优先算法虽然效果看起来很美好,但实际上真正应用的场景反而没有轮询(包括加权轮询)那么多。
性能最优类
负载最低优先类算法是站在服务器的角度来进行分配的,而性能最优优先类算法则是站在客户端的角度来进行分配的,优先将任务分配给处理速度最快的服务器,通过这种方式达到最快响应客户端的目的。
和负载最低优先类算法类似,性能最优优先类算法本质上也是感知了服务器的状态,只是通过响应时间这个外部标准来衡量服务器状态而已。因此性能最优优先类算法存在的问题和负载最低优先类算法类似,复杂度都很高,主要体现在:
1、负载均衡系统需要收集和分析每个服务器每个任务的响应时间,在大量任务处理的场景下,这种收集和统计本身也会消耗较多的性能。
2、为了减少这种统计上的消耗,可以采取采样的方式来统计,即不统计所有任务的响应时间,而是抽样统计部分任务的响应时间来估算整体任务的响应时间。采样统计虽然能够减少性能消耗,但使得复杂度进一步上升,因为要确定合适的采样率,采样率太低会导致结果不准确,采样率太高会导致性能消耗较大,找到合适的采样率也是一件复杂的事情。
3、无论是全部统计还是采样统计,都需要选择合适的周期:是 10 秒内性能最优,还是 1 分钟内性能最优,还是 5 分钟内性能最优……没有放之四海而皆准的周期,需要根据实际业务进行判断和选择,这也是一件比较复杂的事情,甚至出现系统上线后需要不断地调优才能达到最优设计。
Hash类
负载均衡系统根据任务中的某些关键信息进行 Hash 运算,将相同 Hash 值的请求分配到同一台服务器上,这样做的目的主要是为了满足特定的业务需求。例如:
1、源地址 Hash
将来源于同一个源 IP 地址的任务分配给同一个服务器进行处理,适合于存在事务、会话的业务。
2、ID Hash
将某个 ID 标识的业务分配到同一个服务器中进行处理,这里的 ID 一般是临时性数据的 ID(如 session id)。
22.4 - 架构设计03-高可用架构模式
高可用架构-CAP理论
CAP 定理(CAP theorem)又被称作布鲁尔定理(Brewer's theorem),是加州大学伯克利分校的计算机科学家埃里克·布鲁尔(Eric Brewer)在 2000 年的 ACM PODC 上提出的一个猜想。2002 年,麻省理工学院的赛斯·吉尔伯特(Seth Gilbert)和南希·林奇(Nancy Lynch)发表了布鲁尔猜想的证明,使之成为分布式计算领域公认的一个定理。对于设计分布式系统的架构师来说,CAP 是必须掌握的理论。
布鲁尔在提出 CAP 猜想的时候,并没有详细定义 Consistency、Availability、Partition Tolerance 三个单词的明确定义。不同资料对 CAP 的详细定义有一些细微的差别。
为了更好地解释 CAP 理论,这里挑选了 Robert Greiner(http://robertgreiner.com/about/)的文章作为参考基础。Robert Greiner 对 CAP 的理解也经历了一个过程,他写了两篇文章来阐述 CAP 理论,第一篇被标记为“outdated”(有一些中文翻译文章正好参考了第一篇),这里将对比前后两篇解释的差异点,通过对比帮助更加深入地理解 CAP 理论。
CAP理论
第一版解释:
Any distributed system cannot guaranty C, A, and P simultaneously.(http://robertgreiner.com/2014/06/cap-theorem-explained/)
简单翻译为:对于一个分布式计算系统,不可能同时满足一致性(Consistence)、可用性(Availability)、分区容错性(Partition Tolerance)三个设计约束。
第二版解释:
In a distributed system (a collection of interconnected nodes that share data.), you can only have two out of the following three guarantees across a write/read pair: Consistency, Availability, and Partition Tolerance - one of them must be sacrificed.(http://robertgreiner.com/2014/08/cap-theorem-revisited/)
简单翻译为:在一个分布式系统(指互相连接并共享数据的节点的集合)中,当涉及读写操作时,只能保证一致性(Consistence)、可用性(Availability)、分区容错性(Partition Tolerance)三者中的两个,另外一个必须被牺牲。
对比两个版本的定义,有几个很关键的差异点:
1、第二版定义了什么才是 CAP 理论探讨的分布式系统,强调了两点:interconnected 和 share data,因为分布式系统并不一定会互联和共享数据。最简单的例如 Memcache 的集群,相互之间就没有连接和共享数据,因此 Memcache 集群这类分布式系统就不符合 CAP 理论探讨的对象;而 MySQL 集群就是互联和进行数据复制的,因此是 CAP 理论探讨的对象。
2、第二版强调了 write/read pair,这点其实是和上一个差异点一脉相承的。也就是说,CAP 关注的是对数据的读写操作,而不是分布式系统的所有功能。例如,ZooKeeper 的选举机制就不是 CAP 探讨的对象。
相对来说第二版的定义和解释更加严谨,但内容相比第一版来说更加难记一些,所以现在大部分技术人员谈论 CAP 理论时,更多还是按照第一版的定义和解释来说的,虽然第一版不严谨,但非常简单和容易记住。
第二版除了基本概念,三个基本的设计约束也进行了重新阐述:
一致性
(Consistency)
第一版解释:All nodes see the same data at the same time.(所有节点在同一时刻都能看到相同的数据。)
第二版解释:A read is guaranteed to return the most recent write for a given client.(对某个指定的客户端来说,读操作保证能够返回最新的写操作结果。)
第一版解释和第二版解释的主要差异点表现在:
1、第一版从节点 node 的角度描述,第二版从客户端 client 的角度描述。
相比来说,第二版更加符合我们观察和评估系统的方式,即站在客户端的角度来观察系统的行为和特征。
2、第一版的关键词是 see,第二版的关键词是 read。
第一版解释中的 see,其实并不确切,因为节点 node 是拥有数据,而不是看到数据,即使要描述也是用 have;第二版从客户端 client 的读写角度来描述一致性,定义更加精确。
3、第一版强调同一时刻拥有相同数据(same time + same data),第二版并没有强调这点。
这就意味着实际上对于节点来说,可能同一时刻拥有不同数据(same time + different data),这和我们通常理解的一致性是有差异的,为何做这样的改动呢?其实在第一版的详细解释中已经提到了,具体内容如下:
A system has consistency if a transaction starts with the system in a consistent state, and ends with the system in a consistent state. In this model, a system can (and does) shift into an inconsistent state during a transaction, but the entire transaction gets rolled back if there is an error during any stage in the process.
参考上述的解释,对于系统执行事务来说,在事务执行过程中,系统其实处于一个不一致的状态,不同的节点的数据并不完全一致,因此第一版的解释“All nodes see the same data at the same time”是不严谨的。而第二版强调 client 读操作能够获取最新的写结果就没有问题,因为事务在执行过程中,client 是无法读取到未提交的数据的,只有等到事务提交后,client 才能读取到事务写入的数据,而如果事务失败则会进行回滚,client 也不会读取到事务中间写入的数据。
可用性
(Availability)
第一版解释:Every request gets a response on success/failure.
简单翻译为:每个请求都能得到成功或者失败的响应。
第二版解释:A non-failing node will return a reasonable response within a reasonable amount of time (no error or timeout).
简单翻译为:非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。
第一版解释和第二版解释主要差异点表现在:
1、第一版是 every request,第二版强调了 A non-failing node。
第一版的 every request 是不严谨的,因为只有非故障节点才能满足可用性要求,如果节点本身就故障了,发给节点的请求不一定能得到一个响应。
2、第一版的 response 分为 success 和 failure,第二版用了两个 reasonable:reasonable response 和 reasonable time,而且特别强调了 no error or timeout。
第一版的 success/failure 的定义太泛了,几乎任何情况,无论是否符合 CAP 理论,我们都可以说请求成功和失败,因为超时也算失败、错误也算失败、异常也算失败、结果不正确也算失败;即使是成功的响应,也不一定是正确的。例如,本来应该返回 100,但实际上返回了 90,这就是成功的响应,但并没有得到正确的结果。相比之下,第二版的解释明确了不能超时、不能出错,结果是合理的,注意没有说“正确”的结果。例如,应该返回 100 但实际上返回了 90,肯定是不正确的结果,但可以是一个合理的结果。
分区容忍性
(Partition Tolerance)
第一版解释:System continues to work despite message loss or partial failure.
简单翻译为:出现消息丢失或者分区错误时系统能够继续运行。
第二版解释:The system will continue to function when network partitions occur.
简单翻译为:当出现网络分区后,系统能够继续“履行职责”。
第一版解释和第二版解释主要差异点表现在:
1、第一版用的是 work,第二版用的是 function。
work 强调“运行”,只要系统不宕机,我们都可以说系统在 work,返回错误也是 work,拒绝服务也是 work;而 function 强调“发挥作用”“履行职责”,这点和可用性是一脉相承的。也就是说,只有返回 reasonable response 才是 function。相比之下,第二版解释更加明确。
2、第一版描述分区用的是 message loss or partial failure,第二版直接用 network partitions。
对比两版解释,第一版是直接说原因,即 message loss 造成了分区,但 message loss 的定义有点狭隘,因为通常我们说的 message loss(丢包),只是网络故障中的一种;第二版直接说现象,即发生了分区现象,不管是什么原因,可能是丢包,也可能是连接中断,还可能是拥塞,只要导致了网络分区,就通通算在里面。
CAP应用
虽然 CAP 理论定义是三个要素中只能取两个,但放到分布式环境下来思考,我们会发现必须选择 P(分区容忍)要素,因为网络本身无法做到 100% 可靠,有可能出故障,所以分区是一个必然的现象。如果我们选择了 CA 而放弃了 P,那么当发生分区现象时,为了保证 C,系统需要禁止写入,当有写入请求时,系统返回 error(例如,当前系统不允许写入),这又和 A 冲突了,因为 A 要求返回 no error 和 no timeout。因此,分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。
CP
Consistency/Partition Tolerance
如下图所示,为了保证一致性,当发生分区现象后,N1 节点上的数据已经更新到 y,但由于 N1 和 N2 之间的复制通道中断,数据 y 无法同步到 N2,N2 节点上的数据还是 x。这时客户端 C 访问 N2 时,N2 需要返回 Error,提示客户端 C“系统现在发生了错误”,这种处理方式违背了可用性(Availability)的要求,因此 CAP 三者只能满足 CP。

AP
Availability/Partition Tolerance
如下图所示,为了保证可用性,当发生分区现象后,N1 节点上的数据已经更新到 y,但由于 N1 和 N2 之间的复制通道中断,数据 y 无法同步到 N2,N2 节点上的数据还是 x。这时客户端 C 访问 N2 时,N2 将当前自己拥有的数据 x 返回给客户端 C 了,而实际上当前最新的数据已经是 y 了,这就不满足一致性(Consistency)的要求了,因此 CAP 三者只能满足 AP。注意:这里 N2 节点返回 x,虽然不是一个“正确”的结果,但是一个“合理”的结果,因为 x 是旧的数据,并不是一个错乱的值,只是不是最新的数据而已。

高可用架构-CAP细节
当谈到数据一致性时,CAP、ACID、BASE 难免会被我们拿出来讨论,原因在于这三者都是和数据一致性相关的理论,如果不仔细理解三者之间的差别,则可能会陷入一头雾水的状态,不知道应该用哪个才好。
CAP关键细节点
埃里克·布鲁尔(Eric Brewer)在《CAP 理论十二年回顾:“规则”变了》(http://www.infoq.com/cn/articles/cap-twelve-years-later-how-the-rules-have-changed)一文中详细地阐述了理解和应用 CAP 的一些细节点,可能是由于作者写作风格的原因,对于一些非常关键的细节点一句话就带过了,这里我特别提炼出来重点阐述。
1、CAP 关注的粒度是数据,而不是整个系统。
原文:C 与 A 之间的取舍可以在同一系统内以非常细小的粒度反复发生,而每一次的决策可能因为具体的操作,乃至因为牵涉到特定的数据或用户而有所不同。
CAP 理论的定义和解释中,用的都是 system、node 这类系统级的概念,这就给很多人造成了很大的误导,认为我们在进行架构设计时,整个系统要么选择 CP,要么选择 AP。但在实际设计过程中,每个系统不可能只处理一种数据,而是包含多种类型的数据,有的数据必须选择 CP,有的数据必须选择 AP。而如果我们做设计时,从整个系统的角度去选择 CP 还是 AP,就会发现顾此失彼,无论怎么做都是有问题的。
以一个最简单的用户管理系统为例,用户管理系统包含用户账号数据(用户 ID、密码)、用户信息数据(昵称、兴趣、爱好、性别、自我介绍等)。通常情况下,用户账号数据会选择 CP,而用户信息数据会选择 AP,如果限定整个系统为 CP,则不符合用户信息数据的应用场景;如果限定整个系统为 AP,则又不符合用户账号数据的应用场景。
所以在 CAP 理论落地实践时,需要将系统内的数据按照不同的应用场景和要求进行分类,每类数据选择不同的策略(CP 还是 AP),而不是直接限定整个系统所有数据都是同一策略。
2、CAP 是忽略网络延迟的。
这是一个非常隐含的假设,布鲁尔在定义一致性时,并没有将延迟考虑进去。也就是说,当事务提交时,数据能够瞬间复制到所有节点。但实际情况下,从节点 A 复制数据到节点 B,总是需要花费一定时间的。如果是相同机房,耗费时间可能是几毫秒;如果是跨地域的机房,例如北京机房同步到广州机房,耗费的时间就可能是几十毫秒。这就意味着,CAP 理论中的 C 在实践中是不可能完美实现的,在数据复制的过程中,节点 A 和节点 B 的数据并不一致。
不要小看了这几毫秒或者几十毫秒的不一致,对于某些严苛的业务场景,例如和金钱相关的用户余额,或者和抢购相关的商品库存,技术上是无法做到分布式场景下完美的一致性的。而业务上必须要求一致性,因此单个用户的余额、单个商品的库存,理论上要求选择 CP 而实际上 CP 都做不到,只能选择 CA。也就是说,只能单点写入,其他节点做备份,无法做到分布式情况下多点写入。
需要注意的是,这并不意味着这类系统无法应用分布式架构,只是说“单个用户余额、单个商品库存”无法做分布式,但系统整体还是可以应用分布式架构的。例如,下面的架构图是常见的将用户分区的分布式架构。

可以将用户 id 为 0 ~ 100 的数据存储在 Node 1,将用户 id 为 101 ~ 200 的数据存储在 Node 2,Client 根据用户 id 来决定访问哪个 Node。对于单个用户来说,读写操作都只能在某个节点上进行;对所有用户来说,有一部分用户的读写操作在 Node 1 上,有一部分用户的读写操作在 Node 2 上。
这样的设计有一个很明显的问题就是某个节点故障时,这个节点上的用户就无法进行读写操作了,但站在整体上来看,这种设计可以降低节点故障时受影响的用户的数量和范围,毕竟只影响 20% 的用户肯定要比影响所有用户要好。这也是为什么挖掘机挖断光缆后,支付宝只有一部分用户会出现业务异常,而不是所有用户业务异常的原因。
2、正常运行情况下,不存在 CP 和 AP 的选择,可以同时满足 CA。
CAP 理论告诉我们分布式系统只能选择 CP 或者 AP,但其实这里的前提是系统发生了“分区”现象。如果系统没有发生分区现象,也就是说 P 不存在的时候(节点间的网络连接一切正常),我们没有必要放弃 C 或者 A,应该 C 和 A 都可以保证,这就要求架构设计的时候既要考虑分区发生时选择 CP 还是 AP,也要考虑分区没有发生时如何保证 CA。
同样以用户管理系统为例,即使是实现 CA,不同的数据实现方式也可能不一样:用户账号数据可以采用“消息队列”的方式来实现 CA,因为消息队列可以比较好地控制实时性,但实现起来就复杂一些;而用户信息数据可以采用“数据库同步”的方式来实现 CA,因为数据库的方式虽然在某些场景下可能延迟较高,但使用起来简单。
3、放弃并不等于什么都不做,需要为分区恢复后做准备。
CAP 理论告诉我们三者只能取两个,需要“牺牲”(sacrificed)另外一个,这里的“牺牲”是有一定误导作用的,因为“牺牲”让很多人理解成什么都不做。实际上,CAP 理论的“牺牲”只是说在分区过程中我们无法保证 C 或者 A,但并不意味着什么都不做。因为在系统整个运行周期中,大部分时间都是正常的,发生分区现象的时间并不长。例如,99.99% 可用性(俗称 4 个 9)的系统,一年运行下来,不可用的时间只有 50 分钟;99.999%(俗称 5 个 9)可用性的系统,一年运行下来,不可用的时间只有 5 分钟。分区期间放弃 C 或者 A,并不意味着永远放弃 C 和 A,可以在分区期间进行一些操作,从而让分区故障解决后,系统能够重新达到 CA 的状态。
最典型的就是在分区期间记录一些日志,当分区故障解决后,系统根据日志进行数据恢复,使得重新达到 CA 状态。同样以用户管理系统为例,对于用户账号数据,假设选择了 CP,则分区发生后,节点 1 可以继续注册新用户,节点 2 无法注册新用户(这里就是不符合 A 的原因,因为节点 2 收到注册请求后会返回 error),此时节点 1 可以将新注册但未同步到节点 2 的用户记录到日志中。当分区恢复后,节点 1 读取日志中的记录,同步给节点 2,当同步完成后,节点 1 和节点 2 就达到了同时满足 CA 的状态。
而对于用户信息数据,假设选择了 AP,则分区发生后,节点 1 和节点 2 都可以修改用户信息,但两边可能修改不一样。例如,用户在节点 1 中将爱好改为“旅游、美食、跑步”,然后用户在节点 2 中将爱好改为“美食、游戏”,节点 1 和节点 2 都记录了未同步的爱好数据,当分区恢复后,系统按照某个规则来合并数据。例如,按照“最后修改优先规则”将用户爱好修改为“美食、游戏”,按照“字数最多优先规则”则将用户爱好修改为“旅游,美食、跑步”,也可以完全将数据冲突报告出来,由人工来选择具体应该采用哪一条。
ACID
ACID 是数据库管理系统为了保证事务的正确性而提出来的一个理论,ACID 包含四个约束。
1、Atomicity(原子性)
一个事务中的所有操作,要么全部完成,要么全部不完成,不会在中间某个环节结束。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
2、Consistency(一致性)在事务开始之前和事务结束以后,数据库的完整性没有被破坏。
3、Isolation(隔离性)数据库允许多个并发事务同时对数据进行读写和修改的能力。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
4、Durability(持久性)事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
可以看到,ACID 中的 A(Atomicity)和 CAP 中的 A(Availability)意义完全不同,而 ACID 中的 C 和 CAP 中的 C 名称虽然都是一致性,但含义也完全不一样。ACID 中的 C 是指数据库的数据完整性,而 CAP 中的 C 是指分布式节点中的数据一致性。再结合 ACID 的应用场景是数据库事务,CAP 关注的是分布式系统数据读写这个差异点来看,其实 CAP 和 ACID 的对比就类似关公战秦琼,虽然关公和秦琼都是武将,但其实没有太多可比性。
BASE
BASE 是指基本可用(Basically Available)、软状态( Soft State)、最终一致性( Eventual Consistency),核心思想是即使无法做到强一致性(CAP 的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性。
1、基本可用(Basically Available)
分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
这里的关键词是“部分”和“核心”,具体选择哪些作为可以损失的业务,哪些是必须保证的业务,是一项有挑战的工作。例如,对于一个用户管理系统来说,“登录”是核心功能,而“注册”可以算作非核心功能。因为未注册的用户本来就还没有使用系统的业务,注册不了最多就是流失一部分用户,而且这部分用户数量较少。如果用户已经注册但无法登录,那就意味用户无法使用系统。例如,充了钱的游戏不能玩了、云存储不能用了……这些会对用户造成较大损失,而且登录用户数量远远大于新注册用户,影响范围更大。
2、软状态(Soft State)
允许系统存在中间状态,而该中间状态不会影响系统整体可用性。这里的中间状态就是 CAP 理论中的数据不一致。
3、最终一致性(Eventual Consistency)
系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。
这里的关键词是“一定时间” 和 “最终”,“一定时间”和数据的特性是强关联的,不同的数据能够容忍的不一致时间是不同的。举一个微博系统的例子,用户账号数据最好能在 1 分钟内就达到一致状态,因为用户在 A 节点注册或者登录后,1 分钟内不太可能立刻切换到另外一个节点,但 10 分钟后可能就重新登录到另外一个节点了;而用户发布的最新微博,可以容忍 30 分钟内达到一致状态,因为对于用户来说,看不到某个明星发布的最新微博,用户是无感知的,会认为明星没有发布微博。“最终”的含义就是不管多长时间,最终还是要达到一致性的状态。
BASE 理论本质上是对 CAP 的延伸和补充,更具体地说,是对 CAP 中 AP 方案的一个补充。前面在剖析 CAP 理论时,提到了其实和 BASE 相关的两点:
1、CAP 理论是忽略延时的,而实际应用中延时是无法避免的。
这一点就意味着完美的 CP 场景是不存在的,即使是几毫秒的数据复制延迟,在这几毫秒时间间隔内,系统是不符合 CP 要求的。因此 CAP 中的 CP 方案,实际上也是实现了最终一致性,只是“一定时间”是指几毫秒而已。
2、AP 方案中牺牲一致性只是指分区期间,而不是永远放弃一致性。
这一点其实就是 BASE 理论延伸的地方,分区期间牺牲一致性,但分区故障恢复后,系统应该达到最终一致性。
综合上面的分析,ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。
FMEA排查可用性隐患
FMEA介绍
FMEA(Failure mode and effects analysis,故障模式与影响分析)又称为失效模式与后果分析、失效模式与效应分析、故障模式与后果分析等,专栏采用“故障模式与影响分析”,因为这个中文翻译更加符合可用性的语境。FMEA 是一种在各行各业都有广泛应用的可用性分析方法,通过对系统范围内潜在的故障模式加以分析,并按照严重程度进行分类,以确定失效对于系统的最终影响。
FMEA 最早是在美国军方开始应用的,20 世纪 40 年代后期,美国空军正式采用了 FMEA。尽管最初是在军事领域建立的方法,但 FMEA 方法现在已广泛应用于各种各样的行业,包括半导体加工、餐饮服务、塑料制造、软件及医疗保健行业。FMEA 之所以能够在这些差异很大的领域都得到应用,根本原因在于 FMEA 是一套分析和思考的方法,而不是某个领域的技能或者工具。
回到软件架构设计领域,FMEA 并不能指导我们如何做架构设计,而是当我们设计出一个架构后,再使用 FMEA 对这个架构进行分析,看看架构是否还存在某些可用性的隐患。
FMEA 方法
在架构设计领域,FMEA 的具体分析方法是:
- 给出初始的架构设计图。
- 假设架构中某个部件发生故障。
- 分析此故障对系统功能造成的影响。
- 根据分析结果,判断架构是否需要进行优化。
FMEA 分析的方法其实很简单,就是一个 FMEA 分析表,常见的 FMEA 分析表格包含下面部分。
1.功能点
当前的 FMEA 分析涉及的功能点,注意这里的“功能点”指的是从用户角度来看的,而不是从系统各个模块功能点划分来看的。例如,对于一个用户管理系统,使用 FMEA 分析时 “登录”“注册”才是功能点,而用户管理系统中的数据库存储功能、Redis 缓存功能不能作为 FMEA 分析的功能点。
2.故障模式
故障模式指的是系统会出现什么样的故障,包括故障点和故障形式。需要特别注意的是,这里的故障模式并不需要给出真正的故障原因,我们只需要假设出现某种故障现象即可,例如 MySQL 响应时间达到 3 秒。造成 MySQL 响应时间达到 3 秒可能的原因很多:磁盘坏道、慢查询、服务器到 MySQL 的连接网络故障、MySQL bug 等,我们并不需要在故障模式中一一列出来,而是在后面的“故障原因”一节中列出来。因为在实际应用过程中,不管哪种原因,只要现象是一样的,对业务的影响就是一样的。
此外,故障模式的描述要尽量精确,多使用量化描述,避免使用泛化的描述。例如,推荐使用“MySQL 响应时间达到 3 秒”,而不是“MySQL 响应慢”。
3.故障影响
当发生故障模式中描述的故障时,功能点具体会受到什么影响。常见的影响有:功能点偶尔不可用、功能点完全不可用、部分用户功能点不可用、功能点响应缓慢、功能点出错等。
故障影响也需要尽量准确描述。例如,推荐使用“20% 的用户无法登录”,而不是“大部分用户无法登录”。要注意这里的数字不需要完全精确,比如 21.25% 这样的数据其实是没有必要的,我们只需要预估影响是 20% 还是 40%。
4.严重程度
严重程度指站在业务的角度故障的影响程度,一般分为“致命 / 高 / 中 / 低 / 无”五个档次。严重程度按照这个公式进行评估:严重程度 = 功能点重要程度 × 故障影响范围 × 功能点受损程度。同样以用户管理系统为例:登录功能比修改用户资料要重要得多,80% 的用户比 20% 的用户范围更大,完全无法登录比登录缓慢要更严重。因此我们可以得出如下故障模式的严重程度。
- 致命:超过 70% 用户无法登录。
- 高:超过 30% 的用户无法登录。
- 中:所有用户登录时间超过 5 秒。
- 低:10% 的用户登录时间超过 5 秒。
- 中:所有用户都无法修改资料。
- 低:20% 的用户无法修改头像。
对于某个故障的影响到底属于哪个档次,有时会出现一些争议。例如,“所有用户都无法修改资料”,有的人认为是高,有的人可能认为是中,这个没有绝对标准,一般建议相关人员讨论确定即可。也不建议花费太多时间争论,争执不下时架构师裁定即可。
5.故障原因
“故障模式”中只描述了故障的现象,并没有单独列出故障原因。主要原因在于不管什么故障原因,故障现象相同,对功能点的影响就相同。那为何这里还要单独将故障原因列出来呢?主要原因有这几个:
1、不同的故障原因发生概率不相同
例如,导致 MySQL 查询响应慢的原因可能是 MySQL bug,也可能是没有索引。很明显“MySQL bug”的概率要远远低于“没有索引”;而不同的概率又会影响我们具体如何应对这个故障。
2、不同的故障原因检测手段不一样
例如,磁盘坏道导致 MySQL 响应慢,那我们需要增加机器的磁盘坏道检查,这个检查很可能不是当前系统本身去做,而是另外运维专门的系统;如果是慢查询导致 MySQL 慢,那我们只需要配置 MySQL 的慢查询日志即可。
3、不同的故障原因的处理措施不一样
例如,如果是 MySQL bug,我们的应对措施只能是升级 MySQL 版本;如果是没有索引,我们的应对措施就是增加索引。
6.故障概率
这里的概率就是指某个具体故障原因发生的概率。例如,磁盘坏道的概率、MySQL bug 的概率、没有索引的概率。一般分为“高 / 中 / 低”三档即可,具体评估的时候需要有以下几点需要重点关注。
1、硬件
硬件随着使用时间推移,故障概率会越来越高。例如,新的硬盘坏道几率很低,但使用了 3 年的硬盘,坏道几率就会高很多。
2、开源系统
成熟的开源系统 bug 率低,刚发布的开源系统 bug 率相比会高一些;自己已经有使用经验的开源系统 bug 率会低,刚开始尝试使用的开源系统 bug 率会高。自研系统和开源系统类似,成熟的
3、自研系统
故障概率会低,而新开发的系统故障概率会高。
高中低是相对的,只是为了确定优先级以决定后续的资源投入,没有必要绝对量化,因为绝对量化是需要成本的,而且很多时候都没法量化。例如,XX 开源系统是 3 个月故障一次,还是 6 个月才故障一次,是无法评估的。
7.风险程度
风险程度就是综合严重程度和故障概率来一起判断某个故障的最终等级,风险程度 = 严重程度 × 故障概率。因此可能出现某个故障影响非常严重,但其概率很低,最终来看风险程度就低。“某个机房业务瘫痪”对业务影响是致命的,但如果故障原因是“地震”,那概率就很低。例如,广州的地震概率就很低,5 级以上地震的 20 世纪才 1 次(1940 年);如果故障的原因是“机房空调烧坏”,则概率就比地震高很多了,可能是 2 年 1 次;如果故障的原因是“系统所在机架掉电”,这个概率比机房空调又要高了,可能是 1 年 1 次。同样的故障影响,不同的故障原因有不同的概率,最终得到的风险级别就是不同的。
8.已有措施
针对具体的故障原因,系统现在是否提供了某些措施来应对,包括:检测告警、容错、自恢复等。
1、检测告警
最简单的措施就是检测故障,然后告警,系统自己不针对故障进行处理,需要人工干预。
2、容错
检测到故障后,系统能够通过备份手段应对。例如,MySQL 主备机,当业务服务器检测到主机无法连接后,自动连接备机读取数据。
3、自恢复
检测到故障后,系统能够自己恢复。例如,Hadoop 检测到某台机器故障后,能够将存储在这台机器的副本重新分配到其他机器。当然,这里的恢复主要还是指“业务”上的恢复,一般不太可能将真正的故障恢复。例如,Hadoop 不可能将产生了磁盘坏道的磁盘修复成没有坏道的磁盘。
9.规避措施
规避措施指为了降低故障发生概率而做的一些事情,可以是技术手段,也可以是管理手段。例如:
技术手段:为了避免新引入的 MongoDB 丢失数据,在 MySQL 中冗余一份。
管理手段:为了降低磁盘坏道的概率,强制统一更换服务时间超过 2 年的磁盘。
10.解决措施
解决措施指为了能够解决问题而做的一些事情,一般都是技术手段。例如:
1、为了解决密码暴力破解,增加密码重试次数限制。
2、为了解决拖库导致数据泄露,将数据库中的敏感数据加密保存。
3、为了解决非法访问,增加白名单控制。
一般来说,如果某个故障既可以采取规避措施,又可以采取解决措施,那么我们会优先选择解决措施,毕竟能解决问题当然是最好的。但很多时候有些问题是系统自己无法解决的,例如磁盘坏道、开源系统 bug,这类故障只能采取规避措施;系统能够自己解决的故障,大部分是和系统本身功能相关的。
11.后续规划
综合前面的分析,就可以看出哪些故障我们目前还缺乏对应的措施,哪些已有措施还不够,针对这些不足的地方,再结合风险程度进行排序,给出后续的改进规划。这些规划既可以是技术手段,也可以是管理手段;可以是规避措施,也可以是解决措施。同时需要考虑资源的投入情况,优先将风险程度高的系统隐患解决。
例如:
1、地震导致机房业务中断:这个故障模式就无法解决,只能通过备份中心规避,尽量减少影响;而机柜断电导致机房业务中断:可以通过将业务机器分散在不同机柜来规避。
2、敏感数据泄露:这个故障模式可以通过数据库加密的技术手段来解决。
3、MongoDB 断电丢数据:这个故障模式可以通过将数据冗余一份在 MySQL 中,在故障情况下重建数据来规避影响。
FMEA实战
下面以一个简单的样例来模拟一次 FMEA 分析。假设设计一个最简单的用户管理系统,包含登录和注册两个功能,其初始架构是:

初始架构很简单:MySQL 负责存储,Memcache(以下简称 MC)负责缓存,Server 负责业务处理。这个架构通过 FMEA 分析后,能够有什么样的发现,下表是分析的样例(注意,这个样例并不完整,可以自行尝试将这个案例补充完整)。

经过上表的 FMEA 分析,将“后续规划”列的内容汇总一下,最终得到了下面几条需要改进的措施:
1、MySQL 增加备机。
2、MC 从单机扩展为集群。
3、MySQL 双网卡连接。
改进后的架构如下:

高可用存储架构:双机架构
存储高可用方案的本质都是通过将数据复制到多个存储设备,通过数据冗余的方式来实现高可用,其复杂性主要体现在如何应对复制延迟和中断导致的数据不一致问题。因此,对任何一个高可用存储方案,需要从以下几个方面去进行思考和分析:
1、数据如何复制?
2、各个节点的职责是什么?
3、如何应对复制延迟?
4、如何应对复制中断?
常见的高可用存储架构有主备、主从、主主、集群、分区,每一种又可以根据业务的需求进行一些特殊的定制化功能,由此衍生出更多的变种。由于不同业务的定制功能难以通用化,今天将针对业界通用的方案,来分析常见的双机高可用架构:主备、主从、主备 / 主从切换和主主。
主备复制
主备复制是最常见也是最简单的一种存储高可用方案,几乎所有的存储系统都提供了主备复制的功能,例如 MySQL、Redis、MongoDB 等。
1、基本实现
下面是标准的主备方案结构图:

其整体架构比较简单,主备架构中的“备机”主要还是起到一个备份作用,并不承担实际的业务读写操作,如果要把备机改为主机,需要人工操作。
2、优缺点分析
主备复制架构的优点就是简单,表现有:
1)对于客户端来说,不需要感知备机的存在,即使灾难恢复后,原来的备机被人工修改为主机后,对于客户端来说,只是认为主机的地址换了而已,无须知道是原来的备机升级为主机。
2)对于主机和备机来说,双方只需要进行数据复制即可,无须进行状态判断和主备切换这类复杂的操作。
主备复制架构的缺点主要有:
1)备机仅仅只为备份,并没有提供读写操作,硬件成本上有浪费。
2)故障后需要人工干预,无法自动恢复。人工处理的效率是很低的,可能打电话找到能够操作的人就耗费了 10 分钟,甚至如果是深更半夜,出了故障都没人知道。人工在执行恢复操作的过程中也容易出错,因为这类操作并不常见,可能 1 年就 2、3 次,实际操作的时候很可能遇到各种意想不到的问题。
综合主备复制架构的优缺点,内部的后台管理系统使用主备复制架构的情况会比较多,例如学生管理系统、员工管理系统、假期管理系统等,因为这类系统的数据变更频率低,即使在某些场景下丢失数据,也可以通过人工的方式补全。
主从复制
主从复制和主备复制只有一字之差,“从”意思是“随从、仆从”,“备”的意思是备份。可以理解为仆从是要帮主人干活的,这里的干活就是承担“读”的操作。也就是说,主机负责读写操作,从机只负责读操作,不负责写操作。
1、基本实现
下面是标准的主从复制架构:

与主备复制架构比较类似,主要的差别点在于从机正常情况下也是要提供读的操作。
2、优缺点分析
主从复制与主备复制相比,优点有:
1)主从复制在主机故障时,读操作相关的业务可以继续运行。
2)主从复制架构的从机提供读操作,发挥了硬件的性能。
缺点有:
1)主从复制架构中,客户端需要感知主从关系,并将不同的操作发给不同的机器进行处理,复杂度比主备复制要高。
2)主从复制架构中,从机提供读业务,如果主从复制延迟比较大,业务会因为数据不一致出现问题。
3)故障时需要人工干预。
综合主从复制的优缺点,一般情况下,写少读多的业务使用主从复制的存储架构比较多。例如,论坛、BBS、新闻网站这类业务,此类业务的读操作数量是写操作数量的 10 倍甚至 100 倍以上。
双机切换
设计关键
主备复制和主从复制方案存在两个共性的问题:
1、主机故障后,无法进行写操作。
2、如果主机无法恢复,需要人工指定新的主机角色。
双机切换就是为了解决这两个问题而产生的,包括主备切换和主从切换两种方案。简单来说,这两个方案就是在原有方案的基础上增加“切换”功能,即系统自动决定主机角色,并完成角色切换。由于主备切换和主从切换在切换的设计上没有差别,我接下来以主备切换为例,一起来看看双机切换架构是如何实现的。
要实现一个完善的切换方案,必须考虑这几个关键的设计点:
1、主备间状态判断
主要包括两方面: 状态传递的渠道,以及状态检测的内容。
状态传递的渠道:是相互间互相连接,还是第三方仲裁?
状态检测的内容:例如机器是否掉电、进程是否存在、响应是否缓慢等。
2、切换决策
主要包括几方面:切换时机、切换策略、自动程度。
切换时机:什么情况下备机应该升级为主机?是机器掉电后备机才升级,还是主机上的进程不存在就升级,还是主机响应时间超过 2 秒就升级,还是 3 分钟内主机连续重启 3 次就升级等。
切换策略:原来的主机故障恢复后,要再次切换,确保原来的主机继续做主机,还是原来的主机故障恢复后自动成为新的备机?
自动程度:切换是完全自动的,还是半自动的?例如,系统判断当前需要切换,但需要人工做最终的确认操作(例如,单击一下“切换”按钮)。
3、数据冲突
解决当原有故障的主机恢复后,新旧主机之间可能存在数据冲突。例如,用户在旧主机上新增了一条 ID 为 100 的数据,这个数据还没有复制到旧的备机,此时发生了切换,旧的备机升级为新的主机,用户又在新的主机上新增了一条 ID 为 100 的数据,当旧的故障主机恢复后,这两条 ID 都为 100 的数据,应该怎么处理?
以上设计点并没有放之四海而皆准的答案,不同的业务要求不一样,所以切换方案比复制方案不只是多了一个切换功能那么简单,而是复杂度上升了一个量级。形象点来说,如果复制方案的代码是 1000 行,那么切换方案的代码可能就是 10000 行,多出来的那 9000 行就是用于实现上面所讲的 3 个设计点的。
常见架构
根据状态传递渠道的不同,常见的主备切换架构有三种形式:互连式、中介式和模拟式。
互连式
故名思议,互连式就是指主备机直接建立状态传递的渠道,架构图请注意与主备复制架构对比。

可以看到,在主备复制的架构基础上,主机和备机多了一个“状态传递”的通道,这个通道就是用来传递状态信息的。这个通道的具体实现可以有很多方式:
1、可以是网络连接(例如,各开一个端口),也可以是非网络连接(用串口线连接)。
2、可以是主机发送状态给备机,也可以是备机到主机来获取状态信息。
3、可以和数据复制通道共用,也可以独立一条通道。
4、状态传递通道可以是一条,也可以是多条,还可以是不同类型的通道混合(例如,网络 + 串口)。
为了充分利用切换方案能够自动决定主机这个优势,客户端这里也会有一些相应的改变,常见的方式有:
1、为了切换后不影响客户端的访问,主机和备机之间共享一个对客户端来说唯一的地址。例如虚拟 IP,主机需要绑定这个虚拟的 IP。
2、客户端同时记录主备机的地址,哪个能访问就访问哪个;备机虽然能收到客户端的操作请求,但是会直接拒绝,拒绝的原因就是“备机不对外提供服务”。
互连式主备切换主要的缺点在于:
1、如果状态传递的通道本身有故障(例如,网线被人不小心踢掉了),那么备机也会认为主机故障了从而将自己升级为主机,而此时主机并没有故障,最终就可能出现两个主机。
2、虽然可以通过增加多个通道来增强状态传递的可靠性,但这样做只是降低了通道故障概率而已,不能从根本上解决这个缺点,而且通道越多,后续的状态决策会更加复杂,因为对备机来说,可能从不同的通道收到了不同甚至矛盾的状态信息。
中介式
中介式指的是在主备两者之外引入第三方中介,主备机之间不直接连接,而都去连接中介,并且通过中介来传递状态信息,其架构图如下:

对比一下互连式切换架构,可以看到主机和备机不再通过互联通道传递状态信息,而是都将状态上报给中介这一角色。单纯从架构上看,中介式似乎比互连式更加复杂了,首先要引入中介,然后要各自上报状态。然而事实上,中介式架构在状态传递和决策上却更加简单。
连接管理更简单:主备机无须再建立和管理多种类型的状态传递连接通道,只要连接到中介即可,实际上是降低了主备机的连接管理复杂度。
例如,互连式要求主机开一个监听端口,备机来获取状态信息;或者要求备机开一个监听端口,主机推送状态信息到备机;如果还采用了串口连接,则需要增加串口连接管理和数据读取。采用中介式后,主备机都只需要把状态信息发送给中介,或者从中介获取对方的状态信息。无论是发送还是获取,主备机都是作为中介的客户端去操作,复杂度会降低。
状态决策更简单:主备机的状态决策简单了,无须考虑多种类型的连接通道获取的状态信息如何决策的问题,只需要按照下面简单的算法即可完成状态决策。
1)无论是主机还是备机,初始状态都是备机,并且只要与中介断开连接,就将自己降级为备机,因此可能出现双备机的情况。
2)主机与中介断连后,中介能够立刻告知备机,备机将自己升级为主机。
3)如果是网络中断导致主机与中介断连,主机自己会降级为备机,网络恢复后,旧的主机以新的备机身份向中介上报自己的状态。
4)如果是掉电重启或者进程重启,旧的主机初始状态为备机,与中介恢复连接后,发现已经有主机了,保持自己备机状态不变。
5)主备机与中介连接都正常的情况下,按照实际的状态决定是否进行切换。例如,主机响应时间超过 3 秒就进行切换,主机降级为备机,备机升级为主机即可。
虽然中介式架构在状态传递和状态决策上更加简单,但并不意味着这种优点是没有代价的,其关键代价就在于如何实现中介本身的高可用。如果中介自己宕机了,整个系统就进入了双备的状态,写操作相关的业务就不可用了。这就陷入了一个递归的陷阱:为了实现高可用引入中介,但中介本身又要求高可用,于是又要设计中介的高可用方案……如此递归下去就无穷无尽了。
MongoDB 的 Replica Set 采取的就是这种方式,其基本架构如下:

MongoDB(M) 表示主节点,MongoDB(S) 表示备节点,MongoDB(A) 表示仲裁节点。主备节点存储数据,仲裁节点不存储数据。客户端同时连接主节点与备节点,不连接仲裁节点。
幸运的是,开源方案已经有比较成熟的中介式解决方案,例如 ZooKeeper 和 Keepalived。ZooKeeper 本身已经实现了高可用集群架构,因此已经帮我们解决了中介本身的可靠性问题,在工程实践中推荐基于 ZooKeeper 搭建中介式切换架构。
模拟式
模拟式指主备机之间并不传递任何状态数据,而是备机模拟成一个客户端,向主机发起模拟的读写操作,根据读写操作的响应情况来判断主机的状态。其基本架构如下:

对比一下互连式切换架构,我们可以看到,主备机之间只有数据复制通道,而没有状态传递通道,备机通过模拟的读写操作来探测主机的状态,然后根据读写操作的响应情况来进行状态决策。
模拟式切换与互连式切换相比,优点是实现更加简单,因为省去了状态传递通道的建立和管理工作。
简单既是优点,同时也是缺点。因为模拟式读写操作获取的状态信息只有响应信息(例如,HTTP 404,超时、响应时间超过 3 秒等),没有互连式那样多样(除了响应信息,还可以包含 CPU 负载、I/O 负载、吞吐量、响应时间等),基于有限的状态来做状态决策,可能出现偏差。
主主复制
主主复制指的是两台机器都是主机,互相将数据复制给对方,客户端可以任意挑选其中一台机器进行读写操作,下面是基本架构图。

相比主备切换架构,主主复制架构具有如下特点:
1)两台都是主机,不存在切换的概念。
2)客户端无须区分不同角色的主机,随便将读写操作发送给哪台主机都可以。
从上面的描述来看,主主复制架构从总体上来看要简单很多,无须状态信息传递,也无须状态决策和状态切换。然而事实上主主复制架构也并不简单,而是有其独特的复杂性,具体表现在:如果采取主主复制架构,必须保证数据能够双向复制,而很多数据是不能双向复制的。例如:
1)用户注册后生成的用户 ID,如果按照数字增长,那就不能双向复制,否则就会出现 X 用户在主机 A 注册,分配的用户 ID 是 100,同时 Y 用户在主机 B 注册,分配的用户 ID 也是 100,这就出现了冲突。
2)库存不能双向复制。例如,一件商品库存 100 件,主机 A 上减了 1 件变成 99,主机 B 上减了 2 件变成 98,然后主机 A 将库存 99 复制到主机 B,主机 B 原有的库存 98 被覆盖,变成了 99,而实际上此时真正的库存是 97。类似的还有余额数据。
因此,主主复制架构对数据的设计有严格的要求,一般适合于那些临时性、可丢失、可覆盖的数据场景。例如,用户登录产生的 session 数据(可以重新登录生成)、用户行为的日志数据(可以丢失)、论坛的草稿数据(可以丢失)等。
高可用存储架构:集群和分区
数据集群
主备、主从、主主架构本质上都有一个隐含的假设:主机能够存储所有数据,但主机本身的存储和处理能力肯定是有极限的。以 PC 为例,Intel 386 时代服务器存储能力只有几百 MB,Intel 奔腾时代服务器存储能力可以有几十 GB,Intel 酷睿多核时代的服务器可以有几个 TB。单纯从硬件发展的角度来看,似乎发展速度还是挺快的,但如果和业务发展速度对比,那就差得远了。早在 2013 年,Facebook 就有 2500 亿张上传照片,当时这些照片的容量就已经达到了 250 PB 字节(250 × 1024TB),平均一天上传的图片有 3 亿 5000 万张。如此大量的数据,单台服务器肯定是无法存储和处理的,我们必须使用多台服务器来存储数据,这就是数据集群架构。
简单来说,集群就是多台机器组合在一起形成一个统一的系统,这里的“多台”,数量上至少是 3 台;相比而言,主备、主从都是 2 台机器。根据集群中机器承担的不同角色来划分,集群可以分为两类:数据集中集群、数据分散集群。
1、数据集中集群
数据集中集群与主备、主从这类架构相似,也可以称数据集中集群为 1 主多备或者 1 主多从。无论是 1 主 1 从、1 主 1 备,还是 1 主多备、1 主多从,数据都只能往主机中写,而读操作可以参考主备、主从架构进行灵活多变。下图是读写全部到主机的一种架构:

虽然架构上是类似的,但由于集群里面的服务器数量更多,导致复杂度整体更高一些,具体体现在:
1)主机如何将数据复制给备机
主备和主从架构中,只有一条复制通道,而数据集中集群架构中,存在多条复制通道。多条复制通道首先会增大主机复制的压力,某些场景下我们需要考虑如何降低主机复制压力,或者降低主机复制给正常读写带来的压力。
其次,多条复制通道可能会导致多个备机之间数据不一致,某些场景下我们需要对备机之间的数据一致性进行检查和修正。
2)备机如何检测主机状态
主备和主从架构中,只有一台备机需要进行主机状态判断。在数据集中集群架构中,多台备机都需要对主机状态进行判断,而不同的备机判断的结果可能是不同的,如何处理不同备机对主机状态的不同判断,是一个复杂的问题。
3)主机故障后,如何决定新的主机
主从架构中,如果主机故障,将备机升级为主机即可;而在数据集中集群架构中,有多台备机都可以升级为主机,但实际上只能允许一台备机升级为主机,那么究竟选择哪一台备机作为新的主机,备机之间如何协调,这也是一个复杂的问题。
目前开源的数据集中集群以 ZooKeeper 为典型,ZooKeeper 通过 ZAB 算法来解决上述提到的几个问题,但 ZAB 算法的复杂度是很高的。
2、数据分散集群
数据分散集群指多个服务器组成一个集群,每台服务器都会负责存储一部分数据;同时,为了提升硬件利用率,每台服务器又会备份一部分数据。
数据分散集群的复杂点在于如何将数据分配到不同的服务器上,算法需要考虑这些设计点:
1)均衡性
算法需要保证服务器上的数据分区基本是均衡的,不能存在某台服务器上的分区数量是另外一台服务器的几倍的情况。
2)容错性
当出现部分服务器故障时,算法需要将原来分配给故障服务器的数据分区分配给其他服务器。
3)可伸缩性
当集群容量不够,扩充新的服务器后,算法能够自动将部分数据分区迁移到新服务器,并保证扩容后所有服务器的均衡性。
数据分散集群和数据集中集群的不同点在于,数据分散集群中的每台服务器都可以处理读写请求,因此不存在数据集中集群中负责写的主机那样的角色。但在数据分散集群中,必须有一个角色来负责执行数据分配算法,这个角色可以是独立的一台服务器,也可以是集群自己选举出的一台服务器。如果是集群服务器选举出来一台机器承担数据分区分配的职责,则这台服务器一般也会叫作主机,但我们需要知道这里的“主机”和数据集中集群中的“主机”,其职责是有差异的。
Hadoop 的实现就是独立的服务器负责数据分区的分配,这台服务器叫作 Namenode。Hadoop 的数据分区管理架构如下:

下面是 Hadoop 官方的解释,能够说明集中式数据分区管理的基本方式。
HDFS 采用 master/slave 架构。一个 HDFS 集群由一个 Namenode 和一定数目的 Datanodes 组成。Namenode 是一个中心服务器,负责管理文件系统的名字空间(namespace),以及客户端对文件的访问。集群中的 Datanode 一般是一个节点一个,负责管理它所在节点上的存储。HDFS 暴露了文件系统的名字空间,用户能够以文件的形式在上面存储数据。从内部看,一个文件其实被分成一个或多个数据块,这些块存储在一组 Datanode 上。Namenode 执行文件系统的名字空间操作,比如打开、关闭、重命名文件或目录。它也负责确定数据块到具体 Datanode 节点的映射。Datanode 负责处理文件系统客户端的读写请求。在 Namenode 的统一调度下进行数据块的创建、删除和复制操作。
与 Hadoop 不同的是,Elasticsearch 集群通过选举一台服务器来做数据分区的分配,叫作 master node,其数据分区管理架构是:

其中 master 节点的职责如下:
The master node is responsible for lightweight cluster-wide actions such as creating or deleting an index, tracking which nodes are part of the cluster, and deciding which shards to allocate to which nodes. It is important for cluster health to have a stable master node.
来源:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-node.html
数据集中集群架构中,客户端只能将数据写到主机;数据分散集群架构中,客户端可以向任意服务器中读写数据。正是因为这个关键的差异,决定了两种集群的应用场景不同。一般来说,数据集中集群适合数据量不大,集群机器数量不多的场景。例如,ZooKeeper 集群,一般推荐 5 台机器左右,数据量是单台服务器就能够支撑;而数据分散集群,由于其良好的可伸缩性,适合业务数据量巨大、集群机器数量庞大的业务场景。例如,Hadoop 集群、HBase 集群,大规模的集群可以达到上百台甚至上千台服务器。
数据分区
前面我们讨论的存储高可用架构都是基于硬件故障的场景去考虑和设计的,主要考虑当部分硬件可能损坏的情况下系统应该如何处理,但对于一些影响非常大的灾难或者事故来说,有可能所有的硬件全部故障。例如,新奥尔良水灾、美加大停电、洛杉矶大地震等这些极端灾害或者事故,可能会导致一个城市甚至一个地区的所有基础设施瘫痪,这种情况下基于硬件故障而设计的高可用架构不再适用,我们需要基于地理级别的故障来设计高可用架构,这就是数据分区架构产生的背景。
数据分区指将数据按照一定的规则进行分区,不同分区分布在不同的地理位置上,每个分区存储一部分数据,通过这种方式来规避地理级别的故障所造成的巨大影响。采用了数据分区的架构后,即使某个地区发生严重的自然灾害或者事故,受影响的也只是一部分数据,而不是全部数据都不可用;当故障恢复后,其他地区备份的数据也可以帮助故障地区快速恢复业务。
设计一个良好的数据分区架构,需要从多方面去考虑。
1、数据量
数据量的大小直接决定了分区的规则复杂度。例如,使用 MySQL 来存储数据,假设一台 MySQL 存储能力是 500GB,那么 2TB 的数据就至少需要 4 台 MySQL 服务器;而如果数据是 200TB,并不是增加到 800 台的 MySQL 服务器那么简单。如果按照 4 台服务器那样去平行管理 800 台服务器,复杂度会发生本质的变化,具体表现为:
1)800 台服务器里面可能每周都有一两台服务器故障,从 800 台里面定位出 2 台服务器故障,很多情况下并不是一件容易的事情,运维复杂度高。
2)增加新的服务器,分区相关的配置甚至规则需要修改,而每次修改理论上都有可能影响已有的 800 台服务器的运行,不小心改错配置的情况在实践中太常见了。
3)如此大量的数据,如果在地理位置上全部集中于某个城市,风险很大,遇到了水灾、大停电这种灾难性的故障时,数据可能全部丢失,因此分区规则需要考虑地理容灾。
因此,数据量越大,分区规则会越复杂,考虑的情况也越多。
2、分区规则
地理位置有近有远,因此可以得到不同的分区规则,包括洲际分区、国家分区、城市分区。具体采取哪种或者哪几种规则,需要综合考虑业务范围、成本等因素。
通常情况下,洲际分区主要用于面向不同大洲提供服务,由于跨洲通讯的网络延迟已经大到不适合提供在线服务了,因此洲际间的数据中心可以不互通或者仅仅作为备份;国家分区主要用于面向不同国家的用户提供服务,不同国家有不同语言、法律、业务等,国家间的分区一般也仅作为备份;城市分区由于都在同一个国家或者地区内,网络延迟较低,业务相似,分区同时对外提供服务,可以满足业务异地多活之类的需求。
3)复制规则
数据分区指将数据分散在多个地区,在某些异常或者灾难情况下,虽然部分数据受影响,但整体数据并没有全部被影响,本身就相当于一个高可用方案了。但仅仅做到这点还不够,因为每个分区本身的数据量虽然只是整体数据的一部分,但还是很大,这部分数据如果损坏或者丢失,损失同样难以接受。因此即使是分区架构,同样需要考虑复制方案。
常见的分区复制规则有三种:集中式、互备式和独立式。
集中式
集中式备份指存在一个总的备份中心,所有的分区都将数据备份到备份中心,其基本架构如下:

集中式备份架构的优缺点是:
1)设计简单,各分区之间并无直接联系,可以做到互不影响。
2)扩展容易,如果要增加第四个分区(例如,武汉分区),只需要将武汉分区的数据复制到西安备份中心即可,其他分区不受影响。
3)成本较高,需要建设一个独立的备份中心。
互备式
互备式备份指每个分区备份另外一个分区的数据,其基本架构如下:

互备式备份架构的优缺点是:
1)设计比较复杂,各个分区除了要承担业务数据存储,还需要承担备份功能,相互之间互相关联和影响。
2)扩展麻烦,如果增加一个武汉分区,则需要修改广州分区的复制指向武汉分区,然后将武汉分区的复制指向北京分区。而原有北京分区已经备份了的广州分区的数据怎么处理也是个难题,不管是做数据迁移,还是广州分区历史数据保留在北京分区,新数据备份到武汉分区,无论哪种方式都很麻烦。
3)成本低,直接利用已有的设备。
独立式
独立式备份指每个分区自己有独立的备份中心,其基本架构如下:

有一个细节需要特别注意,各个分区的备份并不和原来的分区在一个地方。例如,北京分区的备份放到了天津,上海的放到了杭州,广州的放到了汕头,这样做的主要目的是规避同城或者相同地理位置同时发生灾难性故障的极端情况。如果北京分区机房在朝阳区,而备份机房放在通州区,整个北京停电的话,两个机房都无法工作。
独立式备份架构的优缺点是:
1)设计简单,各分区互不影响。
2)扩展容易,新增加的分区只需要搭建自己的备份中心即可。
3)成本高,每个分区需要独立的备份中心,备份中心的场地成本是主要成本,因此独立式比集中式成本要高很多。
如何设计计算高可用架构
计算高可用的主要设计目标是当出现部分硬件损坏时,计算任务能够继续正常运行。因此计算高可用的本质是通过冗余来规避部分故障的风险,单台服务器是无论如何都达不到这个目标的。所以计算高可用的设计思想很简单:通过增加更多服务器来达到计算高可用。
计算高可用架构的设计复杂度主要体现在任务管理方面,即当任务在某台服务器上执行失败后,如何将任务重新分配到新的服务器进行执行。因此,计算高可用架构设计的关键点有下面两点。
1)哪些服务器可以执行任务
第一种方式和计算高性能中的集群类似,每个服务器都可以执行任务。例如,常见的访问网站的某个页面。
第二种方式和存储高可用中的集群类似,只有特定服务器(通常叫“主机”)可以执行任务。当执行任务的服务器故障后,系统需要挑选新的服务器来执行任务。例如,ZooKeeper 的 Leader 才能处理写操作请求。
2)任务如何重新执行
第一种策略是对于已经分配的任务即使执行失败也不做任何处理,系统只需要保证新的任务能够分配到其他非故障服务器上执行即可。
第二种策略是设计一个任务管理器来管理需要执行的计算任务,服务器执行完任务后,需要向任务管理器反馈任务执行结果,任务管理器根据任务执行结果来决定是否需要将任务重新分配到另外的服务器上执行。
需要注意的是:“任务分配器”是一个逻辑的概念,并不一定要求系统存在一个独立的任务分配器模块。例如:
1)Nginx 将页面请求发送给 Web 服务器,而 CSS/JS 等静态文件直接读取本地缓存。这里的 Nginx 角色是反向代理系统,但是承担了任务分配器的职责,而不需要 Nginx 做反向代理,后面再来一个任务分配器。
2)对于一些后台批量运算的任务,可以设计一个独立的任务分配系统来管理这些批处理任务的执行和分配。
3)ZooKeeper 中的 Follower 节点,当接收到写请求时会将请求转发给 Leader 节点处理,当接收到读请求时就自己处理,这里的 Follower 就相当于一个逻辑上的任务分配器。
接下来将详细阐述常见的计算高可用架构:主备、主从和集群。
主备
主备架构是计算高可用最简单的架构,和存储高可用的主备复制架构类似,但是要更简单一些,因为计算高可用的主备架构无须数据复制,其基本的架构示意图如下:

主备方案的详细设计:
1)主机执行所有计算任务。例如,读写数据、执行操作等。
2)当主机故障(例如,主机宕机)时,任务分配器不会自动将计算任务发送给备机,此时系统处于不可用状态。
3)如果主机能够恢复(不管是人工恢复还是自动恢复),任务分配器继续将任务发送给主机。
4)如果主机不能够恢复(例如,机器硬盘损坏,短时间内无法恢复),则需要人工操作,将备机升为主机,然后让任务分配器将任务发送给新的主机(即原来的备机);同时,为了继续保持主备架构,需要人工增加新的机器作为备机。
根据备机状态的不同,主备架构又可以细分为冷备架构和温备架构。
冷备:备机上的程序包和配置文件都准备好,但备机上的业务系统没有启动(注意:备机的服务器是启动的),主机故障后,需要人工手工将备机的业务系统启动,并将任务分配器的任务请求切换发送给备机。
温备:备机上的业务系统已经启动,只是不对外提供服务,主机故障后,人工只需要将任务分配器的任务请求切换发送到备机即可。冷备可以节省一定的能源,但温备能够大大减少手工操作时间,因此一般情况下推荐用温备的方式。
主备架构的优点就是简单,主备机之间不需要进行交互,状态判断和切换操作由人工执行,系统实现很简单。而缺点正好也体现在“人工操作”这点上,因为人工操作的时间不可控,可能系统已经发生问题了,但维护人员还没发现,等了 1 个小时才发现。发现后人工切换的操作效率也比较低,可能需要半个小时才完成切换操作,而且手工操作过程中容易出错。例如,修改配置文件改错了、启动了错误的程序等。
和存储高可用中的主备复制架构类似,计算高可用的主备架构也比较适合与内部管理系统、后台管理系统这类使用人数不多、使用频率不高的业务,不太适合在线的业务。
主从
和存储高可用中的主从复制架构类似,计算高可用的主从架构中的从机也是要执行任务的。任务分配器需要将任务进行分类,确定哪些任务可以发送给主机执行,哪些任务可以发送给备机执行,其基本的架构示意图如下:

主从方案详细设计:
1)正常情况下,主机执行部分计算任务(如图中的“计算任务 A”),备机执行部分计算任务(如图中的“计算任务 B”)。
2)当主机故障(例如,主机宕机)时,任务分配器不会自动将原本发送给主机的任务发送给从机,而是继续发送给主机,不管这些任务执行是否成功。
3)如果主机能够恢复(不管是人工恢复还是自动恢复),任务分配器继续按照原有的设计策略分配任务,即计算任务 A 发送给主机,计算任务 B 发送给从机。
4)如果主机不能够恢复(例如,机器硬盘损坏,短时间内无法恢复),则需要人工操作,将原来的从机升级为主机(一般只是修改配置即可),增加新的机器作为从机,新的从机准备就绪后,任务分配器继续按照原有的设计策略分配任务。
主从架构与主备架构相比,优缺点有:
优点:主从架构的从机也执行任务,发挥了从机的硬件性能。
缺点:主从架构需要将任务分类,任务分配器会复杂一些。
集群
主备架构和主从架构通过冗余一台服务器来提升可用性,且需要人工来切换主备或者主从。这样的架构虽然简单,但存在一个主要的问题:人工操作效率低、容易出错、不能及时处理故障。因此在可用性要求更加严格的场景中,我们需要系统能够自动完成切换操作,这就是高可用集群方案。
高可用计算的集群方案根据集群中服务器节点角色的不同,可以分为两类:一类是对称集群,即集群中每个服务器的角色都是一样的,都可以执行所有任务;另一类是非对称集群,集群中的服务器分为多个不同的角色,不同的角色执行不同的任务,例如最常见的 Master-Slave 角色。
需要注意的是,计算高可用集群包含 2 台服务器的集群,这点和存储高可用集群不太一样。存储高可用集群把双机架构和集群架构进行了区分;而在计算高可用集群架构中,2 台服务器的集群和多台服务器的集群,在设计上没有本质区别,因此不需要进行区分。
对称集群
对称集群更通俗的叫法是负载均衡集群,因此接下来使用“负载均衡集群”这个通俗的说法,架构示意图如下:

负载均衡集群详细设计:
1)正常情况下,任务分配器采取某种策略(随机、轮询等)将计算任务分配给集群中的不同服务器。
2)当集群中的某台服务器故障后,任务分配器不再将任务分配给它,而是将任务分配给其他服务器执行。
3)当故障的服务器恢复后,任务分配器重新将任务分配给它执行。
负载均衡集群的设计关键点在于两点:
1)任务分配器需要选取分配策略。
2)任务分配器需要检测服务器状态。
任务分配策略比较简单,轮询和随机基本就够了。状态检测稍微复杂一些,既要检测服务器的状态,例如服务器是否宕机、网络是否正常等;同时还要检测任务的执行状态,例如任务是否卡死、是否执行时间过长等。常用的做法是任务分配器和服务器之间通过心跳来传递信息,包括服务器信息和任务信息,然后根据实际情况来确定状态判断条件。
例如,一个在线页面访问系统,正常情况下页面平均会在 500 毫秒内返回,那么状态判断条件可以设计为:1 分钟内响应时间超过 1 秒(包括超时)的页面数量占了 80% 时,就认为服务器有故障。
例如,一个后台统计任务系统,正常情况下任务会在 5 分钟内执行完成,那么状态判断条件可以设计为:单个任务执行时间超过 10 分钟还没有结束,就认为服务器有故障。
通过上面两个案例可以看出,不同业务场景的状态判断条件差异很大,实际设计时要根据业务需求来进行设计和调优。
非对称集群
非对称集群中不同服务器的角色是不同的,不同角色的服务器承担不同的职责。以 Master-Slave 为例,部分任务是 Master 服务器才能执行,部分任务是 Slave 服务器才能执行。非对称集群的基本架构示意图如下:

非对称集群架构详细设计:
1)集群会通过某种方式来区分不同服务器的角色。例如,通过 ZAB 算法选举,或者简单地取当前存活服务器中节点 ID 最小的服务器作为 Master 服务器。
2)任务分配器将不同任务发送给不同服务器。例如,图中的计算任务 A 发送给 Master 服务器,计算任务 B 发送给 Slave 服务器。
3)当指定类型的服务器故障时,需要重新分配角色。例如,Master 服务器故障后,需要将剩余的 Slave 服务器中的一个重新指定为 Master 服务器;如果是 Slave 服务器故障,则并不需要重新分配角色,只需要将故障服务器从集群剔除即可。
非对称集群相比负载均衡集群,设计复杂度主要体现在两个方面:
1)任务分配策略更加复杂:需要将任务划分为不同类型并分配给不同角色的集群节点。
2)角色分配策略实现比较复杂:例如,可能需要使用 ZAB、Raft 这类复杂的算法来实现 Leader 的选举。
以 ZooKeeper 为例:
1)任务分配器:ZooKeeper 中不存在独立的任务分配器节点,每个 Server 都是任务分配器,Follower 收到请求后会进行判断,如果是写请求就转发给 Leader,如果是读请求就自己处理。
2)角色指定:ZooKeeper 通过 ZAB 算法来选举 Leader,当 Leader 故障后,所有的 Follower 节点会暂停读写操作,开始进行选举,直到新的 Leader 选举出来后才继续对 Client 提供服务。
业务高可用保障:异地多活架构
无论是高可用计算架构,还是高可用存储架构,其本质的设计目的都是为了解决部分服务器故障的场景下,如何保证系统能够继续提供服务。但在一些极端场景下,有可能所有服务器都出现故障。例如,典型的有机房断电、机房火灾、地震、水灾……这些极端情况会导致某个系统所有服务器都故障,或者业务整体瘫痪,而且即使有其他地区的备份,把备份业务系统全部恢复到能够正常提供业务,花费的时间也比较长,可能是半小时,也可能是 12 小时。因为备份系统平时不对外提供服务,可能会存在很多隐藏的问题没有发现。如果业务期望达到即使在此类灾难性故障的情况下,业务也不受影响,或者在几分钟内就能够很快恢复,那么就需要设计异地多活架构。
异地多活应用场景
顾名思义,异地多活架构的关键点就是异地、多活,其中异地就是指地理位置上不同的地方,类似于“不要把鸡蛋都放在同一篮子里”;多活就是指不同地理位置上的系统都能够提供业务服务,这里的“活”是活动、活跃的意思。判断一个系统是否符合异地多活,需要满足两个标准:
1)正常情况下,用户无论访问哪一个地点的业务系统,都能够得到正确的业务服务。
2)某个地方业务异常的时候,用户访问其他地方正常的业务系统,能够得到正确的业务服务。
与“活”对应的是字是“备”,备是备份,正常情况下对外是不提供服务的,如果需要提供服务,则需要大量的人工干预和操作,花费大量的时间才能让“备”变成“活”。
单纯从异地多活的描述来看,异地多活很强大,能够保证在灾难的情况下业务都不受影响。那是不是意味着不管什么业务,我们都要去实现异地多活架构呢?其实不然,因为实现异地多活架构不是没有代价的,相反其代价很高,具体表现为:
1)系统复杂度会发生质的变化,需要设计复杂的异地多活架构。
2)成本会上升,毕竟要多在一个或者多个机房搭建独立的一套业务系统。
因此,异地多活虽然功能很强大,但也不是每个业务不管三七二十一都要上异地多活。例如,常见的新闻网站、企业内部的 IT 系统、游戏、博客站点等,如果无法承受异地多活带来的复杂度和成本,是可以不做异地多活的,只需要做异地备份即可。因为这类业务系统即使中断,对用户的影响并不会很大,例如,A 新闻网站看不了,用户换个新闻网站即可。而共享单车、滴滴出行、支付宝、微信这类业务,就需要做异地多活了,这类业务系统中断后,对用户的影响很大。例如,支付宝用不了,就没法买东西了;滴滴用不了,用户就打不到车了。
当然,如果业务规模很大,能够做异地多活的情况下还是尽量。首先,这样能够在异常的场景下给用户提供更好的体验;其次,业务规模很大肯定会伴随衍生的收入,例如广告收入,异地多活能够减少异常场景带来的收入损失。同样以新闻网站为例,虽然从业务的角度来看,新闻类网站对用户影响不大,反正用户也可以从其他地方看到基本相同的新闻,甚至用户几个小时不看新闻也没什么问题。但是从网站本身来看,几个小时不可访问肯定会影响用户对网站的口碑;其次几个小时不可访问,网站上的广告收入损失也会很大。
架构模式
根据地理位置上的距离来划分,异地多活架构可以分为同城异区、跨城异地、跨国异地。接下来详细解释一下每一种架构的细节与优缺点。
同城异区
同城异区指的是将业务部署在同一个城市不同区的多个机房。例如,在北京部署两个机房,一个机房在海淀区,一个在通州区,然后将两个机房用专用的高速网络连接在一起。
如果考虑一些极端场景(例如,美加大停电、新奥尔良水灾),同城异区似乎没什么作用,那为何还要设计同城异区这种架构呢?答案就在于“同城”。
同城的两个机房,距离上一般大约就是几十千米,通过搭建高速的网络,同城异区的两个机房能够实现和同一个机房内几乎一样的网络传输速度。这就意味着虽然是两个不同地理位置上的机房,但逻辑上我们可以将它们看作同一个机房,这样的设计大大降低了复杂度,减少了异地多活的设计和实现复杂度及成本。
那如果采用了同城异区架构,一旦发生新奥尔良水灾这种灾难怎么办呢?很遗憾,答案是无能为力。但需要考虑的是,这种极端灾难发生概率是比较低的,可能几年或者十几年才发生一次。其次,除了这类灾难,机房火灾、机房停电、机房空调故障这类问题发生的概率更高,而且破坏力一样很大。而这些故障场景,同城异区架构都可以很好地解决。因此,结合复杂度、成本、故障发生概率来综合考虑,同城异区是应对机房级别故障的最优架构。
跨城异地
跨城异地指的是业务部署在不同城市的多个机房,而且距离最好要远一些。例如,将业务部署在北京和广州两个机房,而不是将业务部署在广州和深圳的两个机房。
为何跨城异地要强调距离要远呢?前面在介绍同城异区的架构时提到同城异区不能解决新奥尔良水灾这种问题,而两个城市离得太近又无法应对如美加大停电这种问题,跨城异地其实就是为了解决这两类问题的,因此需要在距离上比较远,才能有效应对这类极端灾难事件。
跨城异地虽然能够有效应对极端灾难事件,但“距离较远”这点并不只是一个距离数字上的变化,而是量变引起了质变,导致了跨城异地的架构复杂度大大上升。距离增加带来的最主要问题是两个机房的网络传输速度会降低,这不是以人的意志为转移的,而是物理定律决定的,即光速真空传播大约是每秒 30 万千米,在光纤中传输的速度大约是每秒 20 万千米,再加上传输中的各种网络设备的处理,实际还远远达不到理论上的速度。
除了距离上的限制,中间传输各种不可控的因素也非常多。例如,挖掘机把光纤挖断、中美海底电缆被拖船扯断、骨干网故障等,这些线路很多是第三方维护,针对故障我们根本无能为力也无法预知。例如,广州机房到北京机房,正常情况下 RTT 大约是 50 毫秒左右,遇到网络波动之类的情况,RTT 可能飙升到 500 毫秒甚至 1 秒,更不用说经常发生的线路丢包问题,那延迟可能就是几秒几十秒了。
以上描述的问题,虽然同城异区理论上也会遇到,但由于同城异区距离较短,中间经过的线路和设备较少,问题发生的概率会低很多。而且同城异区距离短,即使是搭建多条互联通道,成本也不会太高,而跨城异区距离太远,搭建或者使用多通道的成本会高不少。
跨城异地距离较远带来的网络传输延迟问题,给异地多活架构设计带来了复杂性,如果要做到真正意义上的多活,业务系统需要考虑部署在不同地点的两个机房,在数据短时间不一致的情况下,还能够正常提供业务。这就引入了一个看似矛盾的地方:数据不一致业务肯定不会正常,但跨城异地肯定会导致数据不一致。
如何解决这个问题呢?重点还是在“数据”上,即根据数据的特性来做不同的架构。如果是强一致性要求的数据,例如银行存款余额、支付宝余额等,这类数据实际上是无法做到跨城异地多活的。我们来看一个假设的例子,假如做一个互联网金融的业务,用户余额支持跨城异地多活,系统分别部署在广州和北京,那么如果挖掘机挖断光缆后,会出现如下场景:
1)用户 A 余额有 10000 元钱,北京和广州机房都是这个数据。
2)用户 A 向用户 B 转了 5000 元钱,这个操作是在广州机房完成的,完成后用户 A 在广州机房的余额是 5000 元。
3)由于广州和北京机房网络被挖掘机挖断,广州机房无法将余额变动通知北京机房,此时北京机房用户 A 的余额还是 10000 元。
4)用户 A 到北京机房又发起转账,此时他看到自己的余额还有 10000 元,于是向用户 C 转账 10000 元,转账完成后用户 A 的余额变为 0。
5)用户 A 到广州机房一看,余额怎么还有 5000 元?于是赶紧又发起转账,转账 5000 元给用户 D;此时广州机房用户 A 的余额也变为 0 了。
最终,本来余额 10000 元的用户 A,却转了 20000 元出去给其他用户。
对于以上这种假设场景,虽然普通用户很难这样自如地操作,但如果真的这么做,被黑客发现后,后果不堪设想。正因为如此,支付宝等金融相关的系统,对余额这类数据,一般不会做跨城异地的多活架构,而只能采用同城异区这种架构。
而对数据一致性要求不那么高,或者数据不怎么改变,或者即使数据丢失影响也不大的业务,跨城异地多活就能够派上用场了。例如,用户登录(数据不一致时用户重新登录即可)、新闻类网站(一天内的新闻数据变化较少)、微博类网站(丢失用户发布的微博或者评论影响不大),这些业务采用跨城异地多活,能够很好地应对极端灾难的场景。
跨国异地
跨国异地指的是业务部署在不同国家的多个机房。相比跨城异地,跨国异地的距离就更远了,因此数据同步的延时会更长,正常情况下可能就有几秒钟了。这种程度的延迟已经无法满足异地多活标准的第一条:“正常情况下,用户无论访问哪一个地点的业务系统,都能够得到正确的业务服务”。例如,假设有一个微博类网站,分别在中国的上海和美国的纽约都建了机房,用户 A 在上海机房发表了一篇微博,此时如果他的一个关注者 B 用户访问到美国的机房,很可能无法看到用户 A 刚刚发表的微博。虽然跨城异地也会有此类同步延时问题,但正常情况下几十毫秒的延时对用户来说基本无感知的;而延时达到几秒钟就感觉比较明显了。
因此,跨国异地的“多活”,和跨城异地的“多活”,实际的含义并不完全一致。跨国异地多活的主要应用场景一般有这几种情况:
1、为不同地区用户提供服务
例如,亚马逊中国是为中国用户服务的,而亚马逊美国是为美国用户服务的,亚马逊中国的用户如果访问美国亚马逊,是无法用亚马逊中国的账号登录美国亚马逊的。
2、只读类业务做多活
例如,谷歌的搜索业务,由于用户搜索资料时,这些资料都已经存在于谷歌的搜索引擎上面,无论是访问英国谷歌,还是访问美国谷歌,搜索结果基本相同,并且对用户来说,也不需要搜索到最新的实时资料,跨国异地的几秒钟网络延迟,对搜索结果是没有什么影响的。
异地多活设计4大技巧
1、保证核心业务的异地多活
“异地多活”是为了保证业务的高可用,但很多架构师在考虑这个“业务”时,会不自觉地陷入一个思维误区:要保证所有业务都能“异地多活”!
假设需要做一个“用户子系统”,这个子系统负责“注册”“登录”“用户信息”三个业务。为了支持海量用户,设计了一个“用户分区”的架构,即正常情况下用户属于某个主分区,每个分区都有其他数据的备份,用户用邮箱或者手机号注册,路由层拿到邮箱或者手机号后,通过 Hash 计算属于哪个中心,然后请求对应的业务中心。基本的架构如下:

这样一个系统,如果 3 个业务要同时实现异地多活,会发现这些难以解决的问题:
1)注册问题
A 中心注册了用户,数据还未同步到 B 中心,此时 A 中心宕机,为了支持注册业务多活,可以挑选 B 中心让用户去重新注册。看起来很容易就支持多活了,但仔细思考一下会发现这样做会有问题:一个手机号只能注册一个账号,A 中心的数据没有同步过来,B 中心无法判断这个手机号是否重复,如果 B 中心让用户注册,后来 A 中心恢复了,发现数据有冲突,怎么解决?实际上是无法解决的,因为同一个手机号注册的账号不能以后一次注册为准;而如果 B 中心不支持本来属于 A 中心的业务进行注册,注册业务的多活又成了空谈。
如果修改业务规则,允许一个手机号注册多个账号不就可以了吗?这样做是不可行的,类似一个手机号只能注册一个账号这种规则,是核心业务规则,修改核心业务规则的代价非常大,几乎所有的业务都要重新设计,为了架构设计去改变业务规则(而且是这么核心的业务规则)是得不偿失的。
2)用户信息问题
用户信息的修改和注册有类似的问题,即 A、B 两个中心在异常的情况下都修改了用户信息,如何处理冲突?
由于用户信息并没有账号那么关键,一种简单的处理方式是按照时间合并,即最后修改的生效。业务逻辑上没问题,但实际操作也有一个很关键的“坑”:怎么保证多个中心所有机器时间绝对一致?在异地多中心的网络下,这个是无法保证的,即使有时间同步也无法完全保证,只要两个中心的时间误差超过 1 秒,数据就可能出现混乱,即先修改的反而生效。
还有一种方式是生成全局唯一递增 ID,这个方案的成本很高,因为这个全局唯一递增 ID 的系统本身又要考虑异地多活,同样涉及数据一致性和冲突的问题。
综合上面的简单分析可以发现,如果“注册”“登录”“用户信息”全部都要支持异地多活,实际上是挺难的,有的问题甚至是无解的。那这种情况下应该如何考虑“异地多活”的架构设计呢?答案其实很简单:优先实现核心业务的异地多活架构!
对于这个模拟案例来说,“登录”才是最核心的业务,“注册”和“用户信息”虽然也是主要业务,但并不一定要实现异地多活,主要原因在于业务影响不同。对于一个日活 1000 万的业务来说,每天注册用户可能是几万,修改用户信息的可能还不到 1 万,但登录用户是 1000 万,很明显应该保证登录的异地多活。
对于新用户来说,注册不了的影响并不明显,因为他还没有真正开始使用业务。用户信息修改也类似,暂时修改不了用户信息,对于其业务不会有很大影响。而如果有几百万用户登录不了,就相当于几百万用户无法使用业务,对业务的影响就非常大了:公司的客服热线很快就被打爆,微博、微信上到处都在传业务宕机,论坛里面到处是抱怨的用户,那就是互联网大事件了!
而登录实现“异地多活”恰恰是最简单的,因为每个中心都有所有用户的账号和密码信息,用户在哪个中心都可以登录。用户在 A 中心登录,A 中心宕机后,用户到 B 中心重新登录即可。如果某个用户在 A 中心修改了密码,此时数据还没有同步到 B 中心,用户到 B 中心登录是无法登录的,这个怎么处理?这个问题其实就涉及另外一个设计技巧了,卖个关子稍后再谈。
2、保证核心数据最终一致性
异地多活本质上是通过异地的数据冗余,来保证在极端异常的情况下业务也能够正常提供给用户,因此数据同步是异地多活架构设计的核心。但大部分架构师在考虑数据同步方案时,会不知不觉地陷入完美主义误区:要所有数据都实时同步!
数据冗余是要将数据从 A 地同步到 B 地,从业务的角度来看是越快越好,最好和本地机房一样的速度最好。但让人头疼的问题正在这里:异地多活理论上就不可能很快,因为这是物理定律决定的(上一期已有说明)。
因此异地多活架构面临一个无法彻底解决的矛盾:业务上要求数据快速同步,物理上正好做不到数据快速同步,因此所有数据都实时同步,实际上是一个无法达到的目标。既然是无法彻底解决的矛盾,那就只能想办法尽量减少影响。有几种方法可以参考:
1)尽量减少异地多活机房的距离,搭建高速网络
这和上一期讲到的同城异区架构类似,但搭建跨城异地的高速网络成本远远超过同城异区的高速网络,成本巨大,一般只有巨头公司才能承担。
2)尽量减少数据同步,只同步核心业务相关的数据
简单来说就是不重要的数据不同步,同步后没用的数据不同步,只同步核心业务相关的数据。
以前面的“用户子系统”为例,用户登录所产生的 token 或者 session 信息,数据量很大,但其实并不需要同步到其他业务中心,因为这些数据丢失后重新登录就可以再次获取了。
这时可能会想到:这些数据丢失后要求用户重新登录,影响用户体验!确实如此,毕竟需要用户重新输入账户和密码信息,或者至少要弹出登录界面让用户点击一次,但相比为了同步所有数据带来的代价,这个影响完全可以接受。为什么这么说呢,还是卖个关子会在后面分析。
3)保证最终一致性,不保证实时一致性
最终一致性就是前面介绍 CAP 理论时提到的 BASE 理论,即业务不依赖数据同步的实时性,只要数据最终能一致即可。例如,A 机房注册了一个用户,业务上不要求能够在 50 毫秒内就同步到所有机房,正常情况下要求 5 分钟同步到所有机房即可,异常情况下甚至可以允许 1 小时或者 1 天后能够一致。
最终一致性在具体实现时,还需要根据不同的数据特征,进行差异化的处理,以满足业务需要。例如,对“账号”信息来说,如果在 A 机房新注册的用户 5 分钟内正好跑到 B 机房了,此时 B 机房还没有这个用户的信息,为了保证业务的正确,B 机房就需要根据路由规则到 A 机房请求数据。
而对“用户信息”来说,5 分钟后同步也没有问题,也不需要采取其他措施来弥补,但还是会影响用户体验,即用户看到了旧的用户信息,这个问题怎么解决呢?好像又是一个解决不了的问题,和前面留下的两个问题一起,在最后再给出答案。
3、采用多种手段同步数据
数据同步是异地多活架构设计的核心,幸运的是基本上存储系统本身都会有同步的功能。例如,MySQL 的主备复制、Redis 的 Cluster 功能、Elasticsearch 的集群功能。这些系统本身的同步功能已经比较强大,能够直接拿来就用,但这也无形中将引入了一个思维误区:只使用存储系统的同步功能!
既然说存储系统本身就有同步功能,而且同步功能还很强大,为何说只使用存储系统是一个思维误区呢?因为虽然绝大部分场景下,存储系统本身的同步功能基本上也够用了,但在某些比较极端的情况下,存储系统本身的同步功能可能难以满足业务需求。
以 MySQL 为例,MySQL 5.1 版本的复制是单线程的复制,在网络抖动或者大量数据同步时,经常发生延迟较长的问题,短则延迟十几秒,长则可能达到十几分钟。而且即使通过监控的手段知道了 MySQL 同步时延较长,也难以采取什么措施,只能干等。
Redis 又是另外一个问题,Redis 3.0 之前没有 Cluster 功能,只有主从复制功能,而为了设计上的简单,Redis 2.8 之前的版本,主从复制有一个比较大的隐患:从机宕机或者和主机断开连接都需要重新连接主机,重新连接主机都会触发全量的主从复制。这时主机会生成内存快照,主机依然可以对外提供服务,但是作为读的从机,就无法提供对外服务了,如果数据量大,恢复的时间会相当长。
综合上面的案例可以看出,存储系统本身自带的同步功能,在某些场景下是无法满足业务需要的。尤其是异地多机房这种部署,各种各样的异常情况都可能出现,当只考虑存储系统本身的同步功能时,就会发现无法做到真正的异地多活。
解决的方案就是拓开思路,避免只使用存储系统的同步功能,可以将多种手段配合存储系统的同步来使用,甚至可以不采用存储系统的同步方案,改用自己的同步方案。
还是以前面的“用户子系统”为例,可以采用如下几种方式同步数据:
1)消息队列方式
对于账号数据,由于账号只会创建,不会修改和删除(假设我们不提供删除功能),我们可以将账号数据通过消息队列同步到其他业务中心。
2)二次读取方式
某些情况下可能出现消息队列同步也延迟了,用户在 A 中心注册,然后访问 B 中心的业务,此时 B 中心本地拿不到用户的账号数据。为了解决这个问题,B 中心在读取本地数据失败时,可以根据路由规则,再去 A 中心访问一次(这就是所谓的二次读取,第一次读取本地,本地失败后第二次读取对端),这样就能够解决异常情况下同步延迟的问题。
3)存储系统同步方式
对于密码数据,由于用户改密码频率较低,而且用户不可能在 1 秒内连续改多次密码,所以通过数据库的同步机制将数据复制到其他业务中心即可,用户信息数据和密码类似。
4)回源读取方式
对于登录的 session 数据,由于数据量很大,可以不同步数据;但当用户在 A 中心登录后,然后又在 B 中心登录,B 中心拿到用户上传的 session id 后,根据路由判断 session 属于 A 中心,直接去 A 中心请求 session 数据即可;反之亦然,A 中心也可以到 B 中心去获取 session 数据。
5)重新生成数据方式
对于“回源读取”场景,如果异常情况下,A 中心宕机了,B 中心请求 session 数据失败,此时就只能登录失败,让用户重新在 B 中心登录,生成新的 session 数据。
注意:以上方案仅仅是示意,实际的设计方案要比这个复杂一些,还有很多细节要考虑。综合上述的各种措施,最后“用户子系统”同步方式整体如下:

4、只保证绝大部分用户的异地多活
前面在给出每个思维误区对应的解决方案时,留下了几个小尾巴:某些场景下我们无法保证 100% 的业务可用性,总是会有一定的损失。例如,密码不同步导致无法登录、用户信息不同步导致用户看到旧的信息等,这个问题怎么解决呢?
其实这个问题涉及异地多活架构设计中一个典型的思维误区:要保证业务 100% 可用!但极端情况下就是会丢一部分数据,就是会有一部分数据不能同步,有没有什么巧妙能做到 100% 可用呢?
很遗憾,答案是没有!异地多活也无法保证 100% 的业务可用,这是由物理规律决定的,光速和网络的传播速度、硬盘的读写速度、极端异常情况的不可控等,都是无法 100% 解决的。所以针对这个思维误区,答案是“忍”!也就是说要忍受这一小部分用户或者业务上的损失,否则本来想为了保证最后的 0.01% 的用户的可用性,做一个完美方案,结果却发现 99.99% 的用户都保证不了了。
对于某些实时强一致性的业务,实际上受影响的用户会更多,甚至可能达到 1/3 的用户。以银行转账这个业务为例,假设小明在北京 XX 银行开了账号,如果小明要转账,一定要北京的银行业务中心才可用,否则就不允许小明自己转账。如果不这样的话,假设在北京和上海两个业务中心实现了实时转账的异地多活,某些异常情况下就可能出现小明只有 1 万元存款,他在北京转给了张三 1 万元,然后又到上海转给了李四 1 万元,两次转账都成功了。这种漏洞如果被人利用,后果不堪设想。
当然,针对银行转账这个业务,虽然无法做到“实时转账”的异地多活,但可以通过特殊的业务手段让转账业务也能实现异地多活。例如,转账业务除了“实时转账”外,还提供“转账申请”业务,即小明在上海业务中心提交转账请求,但上海的业务中心并不立即转账,而是记录这个转账请求,然后后台异步发起真正的转账操作,如果此时北京业务中心不可用,转账请求就可以继续等待重试;假设等待 2 个小时后北京业务中心恢复了,此时上海业务中心去请求转账,发现余额不够,这个转账请求就失败了。小明再登录上来就会看到转账申请失败,原因是“余额不足”。
不过需要注意的是“转账申请”的这种方式虽然有助于实现异地多活,但其实还是牺牲了用户体验的,对于小明来说,本来一次操作的事情,需要分为两次:一次提交转账申请,另外一次是要确认是否转账成功。虽然无法做到 100% 可用性,但并不意味着什么都不能做,为了让用户心里更好受一些,可以采取一些措施进行安抚或者补偿,例如:
1)挂公告
说明现在有问题和基本的问题原因,如果不明确原因或者不方便说出原因,可以发布“技术正在紧急处理”这类比较轻松和有趣的公告。
2)事后对用户进行补偿
例如,送一些业务上可用的代金券、小礼包等,减少用户的抱怨。
3)补充体验
对于为了做异地多活而带来的体验损失,可以想一些方法减少或者规避。以“转账申请”为例,为了让用户不用确认转账申请是否成功,我们可以在转账成功或者失败后直接给用户发个短信,告诉他转账结果,这样用户就不用时不时地登录系统来确认转账是否成功了。
核心思想
异地多活设计的理念可以总结为一句话:采用多种手段,保证绝大部分用户的核心业务异地多活!
异地多活设计4步走
1、业务分级
按照一定的标准将业务进行分级,挑选出核心的业务,只为核心业务设计异地多活,降低方案整体复杂度和实现成本。
常见的分级标准有下面几种:
1)访问量大的业务
以用户管理系统为例,业务包括登录、注册、用户信息管理,其中登录的访问量肯定是最大的。
2)核心业务
以 QQ 为例,QQ 的主场景是聊天,QQ 空间虽然也是重要业务,但和聊天相比,重要性就会低一些,如果要从聊天和 QQ 空间两个业务里面挑选一个做异地多活,那明显聊天要更重要(当然,此类公司如腾讯,应该是两个都实现了异地多活的)。
3)产生大量收入的业务
同样以 QQ 为例,聊天可能很难为腾讯带来收益,因为聊天没法插入广告;而 QQ 空间反而可能带来更多收益,因为 QQ 空间可以插入很多广告,因此如果从收入的角度来看,QQ 空间做异地多活的优先级反而高于 QQ 聊天了。
以一直在举例的用户管理系统为例,“登录”业务符合“访问量大的业务”和“核心业务”这两条标准,因此将登录业务作为核心业务。
2、数据分类
挑选出核心业务后,需要对核心业务相关的数据进一步分析,目的在于识别所有的数据及数据特征,这些数据特征会影响后面的方案设计。
常见的数据特征分析维度有:
1)数据量
这里的数据量包括总的数据量和新增、修改、删除的量。对异地多活架构来说,新增、修改、删除的数据就是可能要同步的数据,数据量越大,同步延迟的几率越高,同步方案需要考虑相应的解决方案。
2)唯一性
唯一性指数据是否要求多个异地机房产生的同类数据必须保证唯一。例如用户 ID,如果两个机房的两个不同用户注册后生成了一样的用户 ID,这样业务上就出错了。
数据的唯一性影响业务的多活设计,如果数据不需要唯一,那就说明两个地方都产生同类数据是可能的;如果数据要求必须唯一,要么只能一个中心点产生数据,要么需要设计一个数据唯一生成的算法。
3)实时性
实时性指如果在 A 机房修改了数据,要求多长时间必须同步到 B 机房,实时性要求越高,对同步的要求越高,方案越复杂。
4)可丢失性
可丢失性指数据是否可以丢失。例如,写入 A 机房的数据还没有同步到 B 机房,此时 A 机房机器宕机会导致数据丢失,那这部分丢失的数据是否对业务会产生重大影响。
例如,登录过程中产生的 session 数据就是可丢失的,因为用户只要重新登录就可以生成新的 session;而用户 ID 数据是不可丢失的,丢失后用户就会失去所有和用户 ID 相关的数据,例如用户的好友、用户的钱等。
5)可恢复性
可恢复性指数据丢失后,是否可以通过某种手段进行恢复,如果数据可以恢复,至少说明对业务的影响不会那么大,这样可以相应地降低异地多活架构设计的复杂度。
例如,用户的微博丢失后,用户重新发一篇一模一样的微博,这个就是可恢复的;或者用户密码丢失,用户可以通过找回密码来重新设置一个新密码,这也算是可以恢复的;而用户账号如果丢失,用户无法登录系统,系统也无法通过其他途径来恢复这个账号,这就是不可恢复的数据。
同样以用户管理系统的登录业务为例,简单分析如下表所示。

3、数据同步
确定数据的特点后,可以根据不同的数据设计不同的同步方案。常见的数据同步方案有:
1)存储系统同步
这是最常用也是最简单的同步方式。例如,使用 MySQL 的数据主从数据同步、主主数据同步。
这类数据同步的优点是使用简单,因为几乎主流的存储系统都会有自己的同步方案;缺点是这类同步方案都是通用的,无法针对业务数据特点做定制化的控制。例如,无论需要同步的数据量有多大,MySQL 都只有一个同步通道。因为要保证事务性,一旦数据量比较大,或者网络有延迟,则同步延迟就会比较严重。
2)消息队列同步
采用独立消息队列进行数据同步,常见的消息队列有 Kafka、ActiveMQ、RocketMQ 等。
消息队列同步适合无事务性或者无时序性要求的数据。例如,用户账号,两个用户先后注册了账号 A 和 B,如果同步时先把 B 同步到异地机房,再同步 A 到异地机房,业务上是没有问题的。而如果是用户密码,用户先改了密码为 m,然后改了密码为 n,同步时必须先保证同步 m 到异地机房,再同步 n 到异地机房;如果反过来,同步后用户的密码就不对了。因此,对于新注册的用户账号,可以采用消息队列同步了;而对于用户密码,就不能采用消息队列同步了。
3)重复生成
数据不同步到异地机房,每个机房都可以生成数据,这个方案适合于可以重复生成的数据。例如,登录产生的 cookie、session 数据、缓存数据等。
同样以用户管理系统的登录业务为例,针对不同的数据特点设计不同的同步方案,如下表所示。

4、异常处理
无论数据同步方案如何设计,一旦出现极端异常的情况,总是会有部分数据出现异常的。例如,同步延迟、数据丢失、数据不一致等。异常处理就是假设在出现这些问题时,系统将采取什么措施来应对。异常处理主要有以下几个目的:
1)问题发生时,避免少量数据异常导致整体业务不可用。
2)问题恢复后,将异常的数据进行修正。
3)对用户进行安抚,弥补用户损失。
常见的异常处理措施有这几类:
多通道同步
多通道同步的含义是采取多种方式来进行数据同步,其中某条通道故障的情况下,系统可以通过其他方式来进行同步,这种方式可以应对同步通道处故障的情况。
以用户管理系统中的用户账号数据为例,我们的设计方案一开始挑选了消息队列的方式进行同步,考虑异常情况下,消息队列同步通道可能中断,也可能延迟很严重;为了保证新注册账号能够快速同步到异地机房,再增加一种 MySQL 同步这种方式作为备份。这样针对用户账号数据同步,系统就有两种同步方式:MySQL 主从同步和消息队列同步。除非两个通道同时故障,否则用户账号数据在其中一个通道异常的情况下,能够通过另外一个通道继续同步到异地机房,如下图所示。

多通道同步设计的方案关键点有:
1)一般情况下,采取两通道即可,采取更多通道理论上能够降低风险,但付出的成本也会增加很多。
2)数据库同步通道和消息队列同步通道不能采用相同的网络连接,否则一旦网络故障,两个通道都同时故障;可以一个走公网连接,一个走内网连接。
3)需要数据是可以重复覆盖的,即无论哪个通道先到哪个通道后到,最终结果是一样的。例如,新建账号数据就符合这个标准,而密码数据则不符合这个标准。
同步和访问结合
这里的访问指异地机房通过系统的接口来进行数据访问。例如业务部署在异地两个机房 A 和 B,B 机房的业务系统通过接口来访问 A 机房的系统获取账号信息,如下图所示。

同步和访问结合方案的设计关键点有:
1)接口访问通道和数据库同步通道不能采用相同的网络连接,不能让数据库同步和接口访问都走同一条网络通道,可以采用接口访问走公网连接,数据库同步走内网连接这种方式。
2)数据有路由规则,可以根据数据来推断应该访问哪个机房的接口来读取数据。例如,有 3 个机房 A、B、C,B 机房拿到一个不属于 B 机房的数据后,需要根据路由规则判断是访问 A 机房接口,还是访问 C 机房接口。
3)由于有同步通道,优先读取本地数据,本地数据无法读取到再通过接口去访问,这样可以大大降低跨机房的异地接口访问数量,适合于实时性要求非常高的数据。
日志记录
日志记录主要用于用户故障恢复后对数据进行恢复,其主要方式是每个关键操作前后都记录相关一条日志,然后将日志保存在一个独立的地方,当故障恢复后,拿出日志跟数据进行对比,对数据进行修复。
为了应对不同级别的故障,日志保存的要求也不一样,常见的日志保存方式有:
1)服务器上保存日志,数据库中保存数据,这种方式可以应对单台数据库服务器故障或者宕机的情况。
2)本地独立系统保存日志,这种方式可以应对某业务服务器和数据库同时宕机的情况。例如,服务器和数据库部署在同一个机架,或者同一个电源线路上,就会出现服务器和数据库同时宕机的情况。
3)日志异地保存,这种方式可以应对机房宕机的情况。
上面不同的日志保存方式,应对的故障越严重,方案本身的复杂度和成本就会越高,实际选择时需要综合考虑成本和收益情况。
用户补偿
无论采用什么样的异常处理措施,都只能最大限度地降低受到影响的范围和程度,无法完全做到没有任何影响。例如,双同步通道有可能同时出现故障、日志记录方案本身日志也可能丢失。因此,无论多么完美的方案,故障的场景下总是可能有一小部分用户业务上出问题,系统无法弥补这部分用户的损失。但可以采用人工的方式对用户进行补偿,弥补用户损失,培养用户的忠诚度。简单来说,系统的方案是为了保证 99.99% 的用户在故障的场景下业务不受影响,人工的补偿是为了弥补 0.01% 的用户的损失。
常见的补偿措施有送用户代金券、礼包、礼品、红包等,有时为了赢得用户口碑,付出的成本可能还会比较大,但综合最终的收益来看还是很值得的。例如暴雪《炉石传说》2017 年回档故障,暴雪给每个用户大约价值人民币 200 元的补偿,结果玩家都求暴雪再来一次回档,形象地说明了玩家对暴雪补偿的充分认可。
应对接口级故障
介绍了异地多活方案。它主要用来应对系统级的故障,例如机器宕机、机房故障和网络故障等问题。这些系统级的故障虽然影响很大,但发生概率较小。在实际业务运行过程中,还有另外一种故障影响可能没有那么大,但发生的概率较高,这就是今天我要跟你聊的接口级的故障。
接口级故障的典型表现就是,系统并没有宕机、网络也没有中断,但业务却出现问题了,例如业务响应缓慢、大量访问超时和大量访问出现异常(给用户弹出提示“无法连接数据库”)。
这类问题的主要原因在于系统压力太大、负载太高,导致无法快速处理业务请求,由此引发更多的后续问题。最常见的情况就是,数据库慢查询将数据库的服务器资源耗尽,导致读写超时,业务读写数据库时要么无法连接数据库、要么超时,最终用户看到的现象就是访问很慢,一会儿访问抛出异常,一会儿访问又是正常结果。
如果进一步探究,导致接口级故障的原因可以分为两大类:
1)内部原因:包括程序 bug 导致死循环,某个接口导致数据库慢查询,程序逻辑不完善导致耗尽内存等。
2)外部原因:包括黑客攻击,促销或者抢购引入了超出平时几倍甚至几十倍的用户,第三方系统大量请求,第三方系统响应缓慢等。
解决接口级故障的核心思想和异地多活基本类似,都是优先保证核心业务和优先保证绝大部分用户。常见的应对方法有四种,降级、熔断、限流和排队,下面会一一讲解。
降级
降级指系统将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。
例如,论坛可以降级为只能看帖子,不能发帖子;也可以降级为只能看帖子和评论,不能发评论;而 App 的日志上传接口,可以完全停掉一段时间,这段时间内 App 都不能上传日志。
降级的核心思想就是丢车保帅,优先保证核心业务。
常见的实现降级的方式有两种:
系统后门降级
简单来说,就是系统预留了后门用于降级操作。例如,系统提供一个降级 URL,当访问这个 URL 时,就相当于执行降级指令,具体的降级指令通过 URL 的参数传入即可。这种方案有一定的安全隐患,所以也会在 URL 中加入密码这类安全措施。
系统后门降级的方式实现成本低,但主要缺点是如果服务器数量多,需要一台一台去操作,效率比较低,这在故障处理争分夺秒的场景下是比较浪费时间的。
独立降级系统
为了解决系统后门降级方式的缺点,我们可以将降级操作独立到一个单独的系统中,实现复杂的权限管理、批量操作等功能。
其基本架构如下:

熔断
熔断是指按照规则停掉外部接口的访问,防止某些外部接口故障导致自己的系统处理能力急剧下降或者出故障。

熔断和降级是两个比较容易混淆的概念,因为单纯从名字上看,好像都有禁止某个功能的意思。但它们的内涵是不同的,因为降级的目的是应对系统自身的故障,而熔断的目的是应对依赖的外部系统故障的情况。
假设一个这样的场景:A 服务的 X 功能依赖 B 服务的某个接口,当 B 服务的接口响应很慢的时候,A 服务的 X 功能响应肯定也会被拖慢,进一步导致 A 服务的线程都被卡在 X 功能处理上,于是 A 服务的其他功能都会被卡住或者响应非常慢。
这时就需要熔断机制了:A 服务不再请求 B 服务的这个接口,A 服务内部只要发现是请求 B 服务的这个接口就立即返回错误,从而避免 A 服务整个被拖慢甚至拖死。
实现熔断机制有两个关键点:
一是需要有一个统一的 API 调用层,由 API 调用层来进行采样或者统计。如果接口调用散落在代码各处,就没法进行统一处理了。
二是阈值的设计,例如 1 分钟内 30% 的请求响应时间超过 1 秒就熔断,这个策略中的“1 分钟”“30%”“1 秒”都对最终的熔断效果有影响。实践中,一般都是先根据分析确定阈值,然后上线观察效果,再进行调优。
限流
降级是从系统功能优先级的角度考虑如何应对故障,而限流则是从用户访问压力的角度来考虑如何应对故障。限流指只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃。
虽然“丢弃”这个词听起来让人不太舒服,但保证一部分请求能够正常响应,总比全部请求都不能响应要好得多。
限流一般都是系统内实现的,常见的限流方式可以分为两类:基于请求限流和基于资源限流。
基于请求限流
基于请求限流指从外部访问的请求角度考虑限流,常见的方式有两种。
第一种是限制总量,也就是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量,例如:某个直播间限制总用户数上限为 100 万,超过 100 万后新的用户无法进入;某个抢购活动商品数量只有 100 个,限制参与抢购的用户上限为 1 万个,1 万以后的用户直接拒绝。
第二种是限制时间量,也就是限制一段时间内某个指标的上限,例如 1 分钟内只允许 10000 个用户访问;每秒请求峰值最高为 10 万。
无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值。例如系统设定了 1 分钟 10000 个用户,但实际上 6000 个用户的时候系统就扛不住了;或者达到 1 分钟 10000 用户后,其实系统压力还不大,但此时已经开始丢弃用户访问了。
即使找到了合适的阈值,基于请求限流还面临硬件相关的问题。例如一台 32 核的机器和 64 核的机器处理能力差别很大,阈值是不同的,可能有的技术人员以为简单根据硬件指标进行数学运算就可以得出来,实际上这样是不可行的,64 核的机器比 32 核的机器,业务处理性能并不是 2 倍的关系,可能是 1.5 倍,甚至可能是 1.1 倍。
为了找到合理的阈值,通常情况下可以采用性能压测来确定阈值,但性能压测也存在覆盖场景有限的问题,可能出现某个性能压测没有覆盖的功能导致系统压力很大;另外一种方式是逐步优化:先设定一个阈值然后上线观察运行情况,发现不合理就调整阈值。
基于上述的分析,根据阈值来限制访问量的方式更多的适应于业务功能比较简单的系统,例如负载均衡系统、网关系统、抢购系统等。
基于资源限流
基于请求限流是从系统外部考虑的,而基于资源限流是从系统内部考虑的,也就是找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源包括连接数、文件句柄、线程数和请求队列等。
例如,采用 Netty 来实现服务器,每个进来的请求都先放入一个队列,业务线程再从队列读取请求进行处理,队列长度最大值为 10000,队列满了就拒绝后面的请求;也可以根据 CPU 的负载或者占用率进行限流,当 CPU 的占用率超过 80% 的时候就开始拒绝新的请求。
基于资源限流相比基于请求限流能够更加有效地反映当前系统的压力,但实际设计时也面临两个主要的难点:如何确定关键资源,以及如何确定关键资源的阈值。
通常情况下,这也是一个逐步调优的过程:设计的时候先根据推断选择某个关键资源和阈值,然后测试验证,再上线观察,如果发现不合理,再进行优化。
限流算法
为了更好地实现前面描述的各种限流方式,通常情况下我们会基于限流算法来设计方案。常见的限流算法有两大类四小类,它们的实现原理和优缺点各不相同,在实际设计的时候需要根据业务场景来选择。
1、时间窗
第一大类是时间窗算法,它会限制一定时间窗口内的请求量或者资源消耗量,根据实现方式又可以细分为“固定时间窗”和“滑动时间窗”。
1)固定时间窗
固定时间窗算法的实现原理是,统计固定时间周期内的请求量或者资源消耗量,超过限额就会启动限流,如下图所示:

它的优点是实现简单,缺点是存在临界点问题。例如上图中的红蓝两点只间隔了短短 10 秒,期间的请求数却已经达到 200,超过了算法规定的限额(1 分钟内处理 100)。但是因为这些请求分别来自两个统计窗口,从单个窗口来看还没有超出限额,所以并不会启动限流,结果可能导致系统因为压力过大而挂掉。
2)滑动时间窗
为了解决临界点问题,滑动时间窗算法应运而生,它的实现原理是,两个统计周期部分重叠,从而避免短时间内的两个统计点分属不同的时间窗的情况,如下图所示:

总体上来看,滑动时间窗的限流效果要比固定时间窗更好,但是实现也会稍微复杂一些。
2、桶算法
第二大类是桶算法,用一个虚拟的“桶”来临时存储一些东西。根据桶里面放的东西,又可以细分为“漏桶”和“令牌桶”。
2.1 漏桶
漏桶算法的实现原理是,将请求放入“桶”(消息队列等),业务处理单元(线程、进程和应用等)从桶里拿请求处理,桶满则丢弃新的请求。
漏桶算法的三个关键实现点:
1)流入速率不固定:可能瞬间流入非常多的请求,例如 0 点签到、整点秒杀。
匀速 (极速) 流出:这是理解漏桶算法的关键,也就是说即使大量请求进入了漏桶,但是从漏桶2)流出的速度是匀速的,速度的最大值就是系统的极限处理速度(对应图中的“极速”)。这样就保证了系统在收到海量请求的时候不被压垮,这是第一层的保护措施。需要注意的是:如果漏桶没有堆积,那么流出速度就等于流入速度,这个时候流出速度就不是匀速的。
3)桶满则丢弃请求:这是第二层保护措施,也就是说漏桶不是无限容量,而是有限容量,例如漏桶最多存储 100 万个请求,桶满了则直接丢弃后面的请求。
漏桶算法的技术本质是总量控制,桶大小是设计关键,具体的优缺点如下:
1)突发大量流量时丢弃的请求较少,因为漏桶本身有缓存请求的作用。
2)桶大小动态调整比较困难(例如 Java BlockingQueue),需要不断的尝试才能找到符合业务需求的最佳桶大小。
3)无法精确控制流出速度,也就是业务的处理速度。
漏桶算法主要适用于瞬时高并发流量的场景(例如刚才提到的 0 点签到、整点秒杀等)。在短短几分钟内涌入大量请求时,为了更好的业务效果和用户体验,即使处理慢一些,也要做到尽量不丢弃用户请求。
2.2 令牌桶算法
令牌桶算法和漏桶算法的不同之处在于,桶中放入的不是请求,而是“令牌”,这个令牌就是业务处理前需要拿到的“许可证”。也就是说,当系统收到一个请求时,先要到令牌桶里面拿“令牌”,拿到令牌才能进一步处理,拿不到就要丢弃请求。
令牌桶算法的三个关键设计点:
1)有一个处理单元往桶里面放令牌,放的速率是可以控制的。
2)桶里面可以累积一定数量的令牌,当突发流量过来的时候,因为桶里面有累积的令牌,此时的业务处理速度会超过令牌放入的速度。
3)如果令牌不足,即使系统有能力处理,也会丢弃请求。
令牌桶算法的技术本质是速率控制,令牌产生的速率是设计关键,具体的优缺点如下:
1)可以动态调整处理速率,实现更加灵活。
2)突发大量流量的时候可能丢弃很多请求,因为令牌桶不能累积太多令牌。
3)实现相对复杂。
令牌桶算法主要适用于两种典型的场景,一种是需要控制访问第三方服务的速度,防止把下游压垮,例如支付宝需要控制访问银行接口的速率;另一种是需要控制自己的处理速度,防止过载,例如压测结果显示系统最大处理 TPS 是 100,那么就可以用令牌桶来限制最大的处理速度。
上面介绍漏桶算法的时候提到漏桶算法可以应对瞬时高并发流量,现在介绍令牌桶算法时,又说令牌桶允许突发流量。
其实,令牌桶的“允许突发”实际上只是“允许一定程度的突发”,比如系统处理能力是每秒 100 TPS,突发到 120 TPS 是可以的,但如果突发到 1000 TPS 的话,系统大概率就被压垮了。所以处理秒杀时高并发流量,还是得用漏桶算法。
令牌桶的算法原本是用于网络设备控制传输速度的,而且它控制的目的是保证一段时间内的平均传输速度。之所以说令牌桶适合突发流量,是指在网络传输的时候,可以允许某段时间内(一般就几秒)超过平均传输速率,这在网络环境下常见的情况就是“网络抖动”。
但这个短时间的突发流量并不会导致雪崩效应,网络设备也能够处理得过来。对应到令牌桶应用到业务处理的场景,就要求即使有突发流量来了,系统自己或者下游系统要真的能够处理的过来,否则令牌桶允许突发流量进来,结果系统或者下游处理不了,那还是会被压垮。
因此,令牌桶在实际设计的时候,桶大小不能像漏桶那样设计很大,需要根据系统的处理能力来进行仔细的估算。例如,漏桶算法的桶容量可以设计为 100 万,但是一个每秒 30 TPS 的令牌桶,桶的容量可能只能设计成 40 左右。海外有的银行给移动钱包提供的接口 TPS 上限是 30,压测到了 40 就真的挂了。
排队
排队实际上是限流的一个变种,限流是直接拒绝用户,排队是让用户等待一段时间,全世界最有名的排队当属 12306 网站排队了。
排队虽然没有直接拒绝用户,但用户等了很长时间后进入系统,体验并不一定比限流好。
由于排队需要临时缓存大量的业务请求,单个系统内部无法缓存这么多数据,一般情况下,排队需要用独立的系统去实现,例如使用 Kafka 这类消息队列来缓存用户请求。
1 号店的“双 11”秒杀排队系统架构:(参考:https://www.infoq.cn/article/yhd-11-11-queuing-system-design)

基本实现摘录如下:
【排队模块】负责接收用户的抢购请求,将请求以先入先出的方式保存下来。每一个参加秒杀活动的商品保存一个队列,队列的大小可以根据参与秒杀的商品数量(或加点余量)自行定义。
【调度模块】负责排队模块到服务模块的动态调度,不断检查服务模块,一旦处理能力有空闲,就从排队队列头上把用户访问请求调入服务模块,并负责向服务模块分发请求。这里调度模块扮演一个中介的角色,但不只是传递请求而已,它还担负着调节系统处理能力的重任。我们可以根据服务模块的实际处理能力,动态调节向排队系统拉取请求的速度。
【服务模块】负责调用真正业务来处理服务,并返回处理结果,调用排队模块的接口回写业务处理结果。
22.5 - 架构设计04-可扩展架构模式
可扩展架构的基本思想和模式
架构可扩展模式内容包括分层架构、SOA 架构、微服务和微内核等。
软件系统的这种天生和内在的可扩展的特性,既是魅力所在,又是难点所在。魅力体现在我们可以通过修改和扩展,不断地让软件系统具备更多的功能和特性,满足新的需求或者顺应技术发展的趋势。而难点体现在如何以最小的代价去扩展系统,因为很多情况下牵一发动全身,扩展时可能出现到处都要改,到处都要推倒重来的情况。这样做的风险不言而喻:改动的地方越多,投入也越大,出错的可能性也越大。因此,如何避免扩展时改动范围太大,是软件架构可扩展性设计的主要思考点。
可扩展的基本思想
可扩展性架构的设计方法很多,但万变不离其宗,所有的可扩展性架构设计,背后的基本思想都可以总结为一个字:拆!
拆,就是将原本大一统的系统拆分成多个规模小的部分,扩展时只修改其中一部分即可,无须整个系统到处都改,通过这种方式来减少改动范围,降低改动风险。
按照不同的思路来拆分软件系统,就会得到不同的架构。常见的拆分思路有如下三种。
1)面向流程拆分:将整个业务流程拆分为几个阶段,每个阶段作为一部分。
2)面向服务拆分:将系统提供的服务拆分,每个服务作为一部分。
3)面向功能拆分:将系统提供的功能拆分,每个功能作为一部分。
理解这三种思路的关键就在于如何理解“流程”“服务”“功能”三者的联系和区别。从范围上来看,从大到小依次为:流程 > 服务 > 功能,单纯从概念解释可能难以理解,但实际上看几个案例就很清楚了。
以 TCP/IP 协议栈为例,来说明“流程”“服务”“功能”的区别和联系。TCP/IP 协议栈和模型图如下图所示。

1)流程
对应 TCP/IP 四层模型,因为 TCP/IP 网络通信流程是:应用层 → 传输层 → 网络层 → 物理 + 数据链路层,不管最上层的应用层是什么,这个流程都不会变。
2)服务
对应应用层的 HTTP、FTP、SMTP 等服务,HTTP 提供 Web 服务,FTP 提供文件服务,SMTP 提供邮件服务,以此类推。
3)功能
每个服务都会提供相应的功能。例如,HTTP 服务提供 GET、POST 功能,FTP 提供上传下载功能,SMTP 提供邮件发送和收取功能。
再以一个简单的学生信息管理系统为例(几乎每个技术人员读书时都做过这样一个系统),拆分方式是:
1、面向流程拆分
展示层 → 业务层 → 数据层 → 存储层,各层含义是:
1)展示层:负责用户页面设计,不同业务有不同的页面。例如,登录页面、注册页面、信息管理页面、安全设置页面等。
2)业务层:负责具体业务逻辑的处理。例如,登录、注册、信息管理、修改密码等业务。
3)数据层:负责完成数据访问。例如,增删改查数据库中的数据、记录事件到日志文件等。
4)存储层:负责数据的存储。例如,关系型数据库 MySQL、缓存系统 Memcache 等。
最终的架构如下:

2、面向服务拆分
将系统拆分为注册、登录、信息管理、安全设置等服务,最终架构示意图如下:

3、面向功能拆分
每个服务都可以拆分为更多细粒度的功能,例如:
1)注册服务:提供多种方式进行注册,包括手机号注册、身份证注册、学生邮箱注册三个功能。
2)登录服务:包括手机号登录、身份证登录、邮箱登录三个功能。
3)信息管理服务:包括基本信息管理、课程信息管理、成绩信息管理等功能。
4)安全设置服务:包括修改密码、安全手机、找回密码等功能。
最终架构图如下:

通过学生信息管理系统的案例可以发现,不同的拆分方式,架构图差异很大。但好像无论哪种方式,最终都是可以实现的。既然如此,何必费尽心机去选择呢,随便挑选一个不就可以了?当然不能随便挑,否则架构设计就没有意义。原因在于:不同的拆分方式,本质上决定了系统的扩展方式。
可扩展方式
在一个理想的环境,团队都是高手,每个程序员都很厉害,对业务都很熟悉,新来的同事很快就知晓所有的细节……那确实不拆分也没有问题。但现实却是:团队有菜鸟程序员,到底是改 A 处实现功能还是改 B 处实现功能,完全取决于他觉得哪里容易改;有的程序员比较粗心;有的程序员某天精神状态不太好;新来的同事不知道历史上某行代码为何那么“恶心”,而轻易地将其改漂亮了一些……所有的这些问题都可能出现,这时候就会发现,合理的拆分,能够强制保证即使程序员出错,出错的范围也不会太广,影响也不会太大。
下面是不同拆分方式应对扩展时的优势。
面向流程拆分
扩展时大部分情况只需要修改某一层,少部分情况可能修改关联的两层,不会出现所有层都同时要修改。例如学生信息管理系统,如果将存储层从 MySQL 扩展为同时支持 MySQL 和 Oracle,那么只需要扩展存储层和数据层即可,展示层和业务层无须变动。
面向服务拆分
对某个服务扩展,或者要增加新的服务时,只需要扩展相关服务即可,无须修改所有的服务。同样以学生管理系统为例,如果需要在注册服务中增加一种“学号注册”功能,则只需要修改“注册服务”和“登录服务”即可,“信息管理服务”和“安全设置”服务无须修改。
面向功能拆分
对某个功能扩展,或者要增加新的功能时,只需要扩展相关功能即可,无须修改所有的服务。同样以学生管理系统为例,如果增加“学号注册”功能,则只需要在系统中增加一个新的功能模块,同时修改“登录功能”模块即可,其他功能都不受影响。
不同的拆分方式,将得到不同的系统架构,典型的可扩展系统架构有:
1)面向流程拆分:分层架构。
2)面向服务拆分:SOA、微服务。
3)面向功能拆分:微内核架构。
当然,这几个系统架构并不是非此即彼的,而是可以在系统架构设计中进行组合使用的。以学生管理系统为例,最终可以这样设计架构:
1)整体系统采用面向服务拆分中的“微服务”架构,拆分为“注册服务”“登录服务”“信息管理服务”“安全服务”,每个服务是一个独立运行的子系统。
2)其中的“注册服务”子系统本身又是采用面向流程拆分的分层架构。
3)“登录服务”子系统采用的是面向功能拆分的“微内核”架构。
传统的可扩展架构模式:分层架构和SOA
相比于高性能、高可用架构模式在最近几十年的迅猛发展来说,可扩展架构模式的发展可以说是步履蹒跚,最近几年火热的微服务模式算是可扩展模式发展历史中为数不多的亮点,但这也导致了现在谈可扩展的时候必谈微服务,甚至微服务架构都成了架构设计的银弹,高性能也用微服务、高可用也用微服务,很多时候这样的架构设计看起来高大上,实际上是大炮打蚊子,违背了架构设计的“合适原则”和“简单原则”。
分层架构
分层架构是很常见的架构模式,它也叫 N 层架构,通常情况下,N 至少是 2 层。例如,C/S 架构、B/S 架构。常见的是 3 层架构(例如,MVC、MVP 架构)、4 层架构,5 层架构的比较少见,一般是比较复杂的系统才会达到或者超过 5 层,比如操作系统内核架构。
按照分层架构进行设计时,根据不同的划分维度和对象,可以得到多种不同的分层架构。
C/S 架构、B/S 架构
划分的对象是整个业务系统,划分的维度是用户交互,即将和用户交互的部分独立为一层,支撑用户交互的后台作为另外一层。

MVC 架构、MVP 架构
划分的对象是单个业务子系统,划分的维度是职责,将不同的职责划分到独立层,但各层的依赖关系比较灵活。例如,MVC 架构中各层之间是两两交互的:

逻辑分层架构
划分的对象可以是单个业务子系统,也可以是整个业务系统,划分的维度也是职责。虽然都是基于职责划分,但逻辑分层架构和 MVC 架构、MVP 架构的不同点在于,逻辑分层架构中的层是自顶向下依赖的。典型的有操作系统内核架构、TCP/IP 架构。例如,下面是 Android 操作系统架构图。

典型的 J2EE 系统架构也是逻辑分层架构,架构图如下:

针对整个业务系统进行逻辑分层的架构图如下:

无论采取何种分层维度,分层架构设计最核心的一点就是需要保证各层之间的差异足够清晰,边界足够明显,让人看到架构图后就能看懂整个架构,这也是分层不能分太多层的原因。否则如果两个层的差异不明显,就会出现程序员小明认为某个功能应该放在 A 层,而程序员老王却认为同样的功能应该放在 B 层,这样会导致分层混乱。如果这样的架构进入实际开发落地,则 A 层和 B 层就会乱成一锅粥,也就失去了分层的意义。
分层架构之所以能够较好地支撑系统扩展,本质在于隔离关注点(separation of concerns),即每个层中的组件只会处理本层的逻辑。比如说,展示层只需要处理展示逻辑,业务层中只需要处理业务逻辑,这样我们在扩展某层时,其他层是不受影响的,通过这种方式可以支撑系统在某层上快速扩展。例如,Linux 内核如果要增加一个新的文件系统,则只需要修改文件存储层即可,其他内核层无须变动。
当然,并不是简单地分层就一定能够实现隔离关注点从而支撑快速扩展,分层时要保证层与层之间的依赖是稳定的,才能真正支撑快速扩展。例如,Linux 内核为了支撑不同的文件系统格式,抽象了 VFS 文件系统接口,架构图如下:

如果没有 VFS,只是简单地将 ext2、ext3、reiser 等文件系统划为“文件系统层”,那么这个分层是达不到支撑可扩展的目的的。因为增加一个新的文件系统后,所有基于文件系统的功能都要适配新的文件系统接口;而有了 VFS 后,只需要 VFS 适配新的文件系统接口,其他基于文件系统的功能是依赖 VFS 的,不会受到影响。
对于操作系统这类复杂的系统,接口本身也可以成为独立的一层。例如,把 VFS 独立为一层是完全可以的。而对于一个简单的业务系统,接口可能就是 Java 语言上的几个 interface 定义,这种情况下如果独立为一层,看起来可能就比较重了。例如,经典的 J2EE 分层架构中,Presentation Layer 和 Business Layer 之间如果硬要拆分一个独立的接口层,则显得有点多余了。
分层结构的另外一个特点就是层层传递,也就是说一旦分层确定,整个业务流程是按照层进行依次传递的,不能在层之间进行跳跃。最简单的 C/S 结构,用户必须先使用 C 层,然后 C 层再传递到 S 层,用户是不能直接访问 S 层的。传统的 J2EE 4 层架构,收到请求后,必须按照下面的方式传递请求:

分层结构的这种约束,好处在于强制将分层依赖限定为两两依赖,降低了整体系统复杂度。例如,Business Layer 被 Presentation Layer 依赖,自己只依赖 Persistence Layer。但分层结构的代价就是冗余,也就是说,不管这个业务有多么简单,每层都必须要参与处理,甚至可能每层都写了一个简单的包装函数。以用户管理系统最简单的一个功能“查看头像”为例。查看头像功能的实现很简单,只是显示一张图片而已,但按照分层分册架构来实现,每层都要写一个简单的函数。
不建议自由选择是否绕过分层的约束,分层架构的优势就体现在通过分层强制约束两两依赖,一旦自由选择绕过分层,时间一长,架构就会变得混乱。除此以外,虽然分层架构的实现在某些场景下看起来有些啰嗦和冗余,但复杂度却很低。
分层架构另外一个典型的缺点就是性能,因为每一次业务请求都需要穿越所有的架构分层,有一些事情是多余的,多少都会有一些性能的浪费。当然,这里所谓的性能缺点只是理论上的分析,实际上分层带来的性能损失,如果放到 20 世纪 80 年代,可能很明显;但到了现在,硬件和网络的性能有了质的飞越,其实分层模式理论上的这点性能损失,在实际应用中,绝大部分场景下都可以忽略不计。
SOA
SOA 的全称是 Service Oriented Architecture,中文翻译为“面向服务的架构”,诞生于上世纪 90 年代,1996 年 Gartner 的两位分析师 Roy W. Schulte 和 Yefim V. Natis 发表了第一个 SOA 的报告。
2005 年,Gartner 预言:到了 2008 年,SOA 将成为 80% 的开发项目的基础(https://www.safaribooksonline.com/library/view/soa-in-practice/9780596529550/ch01s04.html)。历史证明这个预言并不十分靠谱,SOA 虽然在很多企业成功推广,但没有达到占有绝对优势的地步。SOA 更多是在传统企业(例如,制造业、金融业等)落地和推广,在互联网行业并没有大规模地实践和推广。互联网行业推行 SOA 最早的应该是亚马逊,得益于杰弗·贝索斯的远见卓识,亚马逊内部的系统都以服务的方式构造,间接地促使了后来的亚马逊云计算技术的出现。
SOA 出现 的背景是企业内部的 IT 系统重复建设且效率低下,主要体现在:
1)企业各部门有独立的 IT 系统,比如人力资源系统、财务系统、销售系统,这些系统可能都涉及人员管理,各 IT 系统都需要重复开发人员管理的功能。例如,某个员工离职后,需要分别到上述三个系统中删除员工的权限。
2)各个独立的 IT 系统可能采购于不同的供应商,实现技术不同,企业自己也不太可能基于这些系统进行重构。
3)随着业务的发展,复杂度越来越高,更多的流程和业务需要多个 IT 系统合作完成。由于各个独立的 IT 系统没有标准的实现方式(例如,人力资源系统用 Java 开发,对外提供 RPC;而财务系统用 C# 开发,对外提供 SOAP 协议),每次开发新的流程和业务,都需要协调大量的 IT 系统,同时定制开发,效率很低。
为了应对传统 IT 系统存在的问题,SOA 提出了 3 个关键概念。
1)服务
所有业务功能都是一项服务,服务就意味着要对外提供开放的能力,当其他系统需要使用这项功能时,无须定制化开发。
服务可大可小,可简单也可复杂。例如,人力资源管理可以是一项服务,包括人员基本信息管理、请假管理、组织结构管理等功能;而人员基本信息管理也可以作为一项独立的服务,组织结构管理也可以作为一项独立的服务。到底是划分为粗粒度的服务,还是划分为细粒度的服务,需要根据企业的实际情况进行判断。
2)ESB
ESB 的全称是 Enterprise Service Bus,中文翻译为“企业服务总线”。从名字就可以看出,ESB 参考了计算机总线的概念。计算机中的总线将各个不同的设备连接在一起,ESB 将企业中各个不同的服务连接在一起。因为各个独立的服务是异构的,如果没有统一的标准,则各个异构系统对外提供的接口是各式各样的。SOA 使用 ESB 来屏蔽异构系统对外提供各种不同的接口方式,以此来达到服务间高效的互联互通。
3)松耦合
松耦合的目的是减少各个服务间的依赖和互相影响。因为采用 SOA 架构后,各个服务是相互独立运行的,甚至都不清楚某个服务到底有多少对其他服务的依赖。如果做不到松耦合,某个服务一升级,依赖它的其他服务全部故障,这样肯定是无法满足业务需求的。
但实际上真正做到松耦合并没有那么容易,要做到完全后向兼容,是一项复杂的任务。
典型的 SOA 架构样例如下:

SOA 架构是比较高层级的架构设计理念,一般情况下可以说某个企业采用了 SOA 的架构来构建 IT 系统,但不会说某个独立的系统采用了 SOA 架构。例如,某企业采用 SOA 架构,将系统分为“人力资源管理服务”“考勤服务”“财务服务”,但人力资源管理服务本身通常不会再按照 SOA 的架构拆分更多服务,也不会再使用独立的一套 ESB,因为这些系统本身可能就是采购的,ESB 本身也是采购的,如果人力资源系统本身重构为多个子服务,再部署独立的 ESB 系统,成本很高,也没有什么收益。
SOA 解决了传统 IT 系统重复建设和扩展效率低的问题,但其本身也引入了更多的复杂性。SOA 最广为人诟病的就是 ESB,ESB 需要实现与各种系统间的协议转换、数据转换、透明的动态路由等功能。例如,下图中 ESB 将 JSON 转换为 Java(摘自《Microservices vs. Service-Oriented Architecture》)。

下图中 ESB 将 REST 协议转换为 RMI 和 AMQP 两个不同的协议:

ESB 虽然功能强大,但现实中的协议有很多种,如 JMS、WS、HTTP、RPC 等,数据格式也有很多种,如 XML、JSON、二进制、HTML 等。ESB 要完成这么多协议和数据格式的互相转换,工作量和复杂度都很大,而且这种转换是需要耗费大量计算性能的,当 ESB 承载的消息太多时,ESB 本身会成为整个系统的性能瓶颈。
当然,SOA 的 ESB 设计也是无奈之举。回想一下 SOA 的提出背景就可以发现,企业在应用 SOA 时,各种异构的 IT 系统都已经存在很多年了,完全重写或者按照统一标准进行改造的成本是非常大的,只能通过 ESB 方式去适配已经存在的各种异构系统。
深入理解微服务架构
微服务背景
微服务是近几年非常火热的架构设计理念,大部分人认为是 Martin Fowler 提出了微服务概念,但事实上微服务概念的历史要早得多,也不是 Martin Fowler 创造出来的,Martin 只是将微服务进行了系统的阐述(原文链接:https://martinfowler.com/articles/microservices.html)。不过不能否认 Martin 在推动微服务起到的作用,微服务能火,Martin 功不可没。
微服务的定义相信早已耳熟能详,参考维基百科,简单梳理一下微服务的历史(https://en.wikipedia.org/wiki/Microservices#History):
1)2005 年:Dr. Peter Rodgers 在 Web Services Edge 大会上提出了“Micro-Web-Services”的概念。
2)2011 年:一个软件架构工作组使用了“microservice”一词来描述一种架构模式。
3)2012 年:同样是这个架构工作组,正式确定用“microservice”来代表这种架构。
4)2012 年:ThoughtWorks 的 James Lewis 针对微服务概念在 QCon San Francisco 2012 发表了演讲。
5)2014 年:James Lewis 和 Martin Fowler 合写了关于微服务的一篇学术性的文章,详细阐述了微服务。
由于微服务的理念中也包含了“服务”的概念,而 SOA 中也有“服务”的概念,自然而然地会提出疑问:微服务与 SOA 有什么关系?有什么区别?为何有了 SOA 还要提微服务?
微服务与 SOA 的关系
关于 SOA 和微服务的关系和区别,大概分为下面几个典型的观点。
1、微服务是 SOA 的实现方式
如下图所示,这种观点认为 SOA 是一种架构理念,而微服务是 SOA 理念的一种具体实现方法。例如,“微服务就是使用 HTTP RESTful 协议来实现 ESB 的 SOA”“使用 SOA 来构建单个系统就是微服务”和“微服务就是更细粒度的 SOA”。

2、微服务是去掉 ESB 后的 SOA
如下图所示,这种观点认为传统 SOA 架构最广为人诟病的就是庞大、复杂、低效的 ESB,因此将 ESB 去掉,改为轻量级的 HTTP 实现,就是微服务。

3、微服务是一种和 SOA 相似但本质上不同的架构理念
如下图所示,这种观点认为微服务和 SOA 只是有点类似,但本质上是不同的架构设计理念。相似点在于下图中交叉的地方,就是两者都关注“服务”,都是通过服务的拆分来解决可扩展性问题。本质上不同的地方在于几个核心理念的差异:是否有 ESB、服务的粒度、架构设计的目标等。

以上观点看似都有一定的道理,但都有点差别,到底哪个才是准确的呢?单纯从概念上是难以分辨的,对比一下 SOA 和微服务的一些具体做法,再来看看到底哪一种观点更加符合实际情况。
1、服务粒度
整体上来说,SOA 的服务粒度要粗一些,而微服务的服务粒度要细一些。例如,对一个大型企业来说,“员工管理系统”就是一个 SOA 架构中的服务;而如果采用微服务架构,则“员工管理系统”会被拆分为更多的服务,比如“员工信息管理”“员工考勤管理”“员工假期管理”和“员工福利管理”等更多服务。
2、服务通信
SOA 采用了 ESB 作为服务间通信的关键组件,负责服务定义、服务路由、消息转换、消息传递,总体上是重量级的实现。微服务推荐使用统一的协议和格式,例如,RESTful 协议、RPC 协议,无须 ESB 这样的重量级实现。Martin Fowler 将微服务架构的服务通讯理念称为“Smart endpoints and dumb pipes”,简单翻译为“聪明的终端,愚蠢的管道”。之所以用“愚蠢”二字,其实就是与 ESB 对比的,因为 ESB 太强大了,既知道每个服务的协议类型(例如,是 RMI 还是 HTTP),又知道每个服务的数据类型(例如,是 XML 还是 JSON),还知道每个数据的格式(例如,是 2017-01-01 还是 01/01/2017),而微服务的“dumb pipes”仅仅做消息传递,对消息格式和内容一无所知。
3、服务交付
SOA 对服务的交付并没有特殊要求,因为 SOA 更多考虑的是兼容已有的系统;微服务的架构理念要求“快速交付”,相应地要求采取自动化测试、持续集成、自动化部署等敏捷开发相关的最佳实践。如果没有这些基础能力支撑,微服务规模一旦变大(例如,超过 20 个微服务),整体就难以达到快速交付的要求,这也是很多企业在实行微服务时踩过的一个明显的坑,就是系统拆分为微服务后,部署的成本呈指数上升。
4、应用场景
SOA 更加适合于庞大、复杂、异构的企业级系统,这也是 SOA 诞生的背景。这类系统的典型特征就是很多系统已经发展多年,采用不同的企业级技术,有的是内部开发的,有的是外部购买的,无法完全推倒重来或者进行大规模的优化和重构。因为成本和影响太大,只能采用兼容的方式进行处理,而承担兼容任务的就是 ESB。
微服务更加适合于快速、轻量级、基于 Web 的互联网系统,这类系统业务变化快,需要快速尝试、快速交付;同时基本都是基于 Web,虽然开发技术可能差异很大(例如,Java、C++、.NET 等),但对外接口基本都是提供 HTTP RESTful 风格的接口,无须考虑在接口层进行类似 SOA 的 ESB 那样的处理。
综合上述分析,将 SOA 和微服务对比如下:

因此可以看到,SOA 和微服务本质上是两种不同的架构设计理念,只是在“服务”这个点上有交集而已,因此两者的关系应该是上面第三种观点。其实,Martin Fowler 在他的微服务文章中,已经做了很好的提炼:(https://martinfowler.com/articles/microservices.html)
In short, the microservice architectural style is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery.
上述英文的三个关键词分别是:small、lightweight、automated,基本上浓缩了微服务的精华,也是微服务与 SOA 的本质区别所在。
通过前面的详细分析和比较,似乎微服务本质上就是一种比 SOA 要优秀很多的架构模式,那是否意味着我们都应该把架构重构为微服务呢?
其实不然,SOA 和微服务是两种不同理念的架构模式,并不存在孰优孰劣,只是应用场景不同而已。介绍 SOA 时候提到其产生历史背景是因为企业的 IT 服务系统庞大而又复杂,改造成本很高,但业务上又要求其互通,因此才会提出 SOA 这种解决方案。如果将微服务的架构模式生搬硬套到企业级 IT 服务系统中,这些 IT 服务系统的改造成本可能远远超出实施 SOA 的成本。
微服务的陷阱
单纯从上面的对比来看,似乎微服务大大优于 SOA,这也导致了很多团队在实践时不加思考地采用微服务——既不考虑团队的规模,也不考虑业务的发展,也没有考虑基础技术的支撑,只是觉得微服务很牛就赶紧来实施,以为实施了微服务后就什么问题都解决了,而一旦真正实施后才发现掉到微服务的坑里面去了。
看一下微服务具体有哪些坑:
1、服务划分过细,服务间关系复杂
服务划分过细,单个服务的复杂度确实下降了,但整个系统的复杂度却上升了,因为微服务将系统内的复杂度转移为系统间的复杂度了。
从理论的角度来计算,n 个服务的复杂度是 n×(n-1)/2,整体系统的复杂度是随着微服务数量的增加呈指数级增加的。下图形象了说明了整体复杂度:

粗粒度划分服务时,系统被划分为 3 个服务,虽然单个服务较大,但服务间的关系很简单;细粒度划分服务时,虽然单个服务小了一些,但服务间的关系却复杂了很多。
2、服务数量太多,团队效率急剧下降
微服务的“微”字,本身就是一个陷阱,很多团队看到“微”字后,就想到必须将服务拆分得很细,有的团队人员规模是 5 ~ 6 个人,然而却拆分出 30 多个微服务,平均每个人要维护 5 个以上的微服务。
这样做给工作效率带来了明显的影响,一个简单的需求开发就需要涉及多个微服务,光是微服务之间的接口就有 6 ~ 7 个,无论是设计、开发、测试、部署,都需要工程师不停地在不同的服务间切换。
1)开发工程师要设计多个接口,打开多个工程,调试时要部署多个程序,提测时打多个包。
2)测试工程师要部署多个环境,准备多个微服务的数据,测试多个接口。
3)运维工程师每次上线都要操作多个微服务,并且微服务之间可能还有依赖关系。
3、调用链太长,性能下降
由于微服务之间都是通过 HTTP 或者 RPC 调用的,每次调用必须经过网络。一般线上的业务接口之间的调用,平均响应时间大约为 50 毫秒,如果用户的一起请求需要经过 6 次微服务调用,则性能消耗就是 300 毫秒,这在很多高性能业务场景下是难以满足需求的。为了支撑业务请求,可能需要大幅增加硬件,这就导致了硬件成本的大幅上升。
4、调用链太长,问题定位困难
系统拆分为微服务后,一次用户请求需要多个微服务协同处理,任意微服务的故障都将导致整个业务失败。然而由于微服务数量较多,且故障存在扩散现象,快速定位到底是哪个微服务故障是一件复杂的事情。下面是一个典型样例。

5、没有自动化支撑,无法快速交付
如果没有相应的自动化系统进行支撑,都是靠人工去操作,那么微服务不但达不到快速交付的目的,甚至还不如一个大而全的系统效率高。例如:
1)没有自动化测试支撑,每次测试时需要测试大量接口。
2)没有自动化部署支撑,每次部署 6 ~ 7 个服务,几十台机器,运维人员敲 shell 命令逐台部署,手都要敲麻。
3)没有自动化监控,每次故障定位都需要人工查几十台机器几百个微服务的各种状态和各种日志文件。
6、没有服务治理,微服务数量多了后管理混乱
信奉微服务理念的设计人员总是强调微服务的 lightweight 特性,并举出 ESB 的反例来证明微服务的优越之处。但具体实践后就会发现,随着微服务种类和数量越来越多,如果没有服务治理系统进行支撑,微服务提倡的 lightweight 就会变成问题。主要问题有:
1)服务路由:假设某个微服务有 60 个节点,部署在 20 台机器上,那么其他依赖的微服务如何知道这个部署情况呢?
2)服务故障隔离:假设上述例子中的 60 个节点有 5 个节点发生故障了,依赖的微服务如何处理这种情况呢?
3)服务注册和发现:同样是上述的例子,现在我们决定从 60 个节点扩容到 80 个节点,或者将 60 个节点缩减为 40 个节点,新增或者减少的节点如何让依赖的服务知道呢?
如果以上场景都依赖人工去管理,整个系统将陷入一片混乱,最终的解决方案必须依赖自动化的服务管理系统,这时就会发现,微服务所推崇的“lightweight”,最终也发展成和 ESB 几乎一样的复杂程度。
微服务架构最佳实践:方法篇
上面谈了实施微服务需要避免踩的陷阱,简单提炼为:
1)微服务拆分过细,过分强调“small”。
2)微服务基础设施不健全,忽略了“automated”。
3)微服务并不轻量级,规模大了后,“lightweight”不再适应。
针对这些问题,下面看看微服务最佳实践应该如何去做。主要分为方法篇和基础设施篇
服务粒度
针对微服务拆分过细导致的问题,建议基于团队规模进行拆分,类似贝索斯在定义团队规模时提出的“两个披萨”理论(每个团队的人数不能多到两张披萨都不够吃的地步),分享一个认为微服务拆分粒度的“三个火枪手”原则,即一个微服务三个人负责开发。在实施微服务架构时,根据团队规模来划分微服务数量,如果业务规继续发展,团队规模扩大,再将已有的微服务进行拆分。例如,团队最初有 6 个人,那么可以划分为 2 个微服务,随着业务的发展,业务功能越来越多,逻辑越来越复杂,团队扩展到 12 个人,那么可以将已有的 2 个微服务进行拆分,变成 4 个微服务。
为什么是 3 个人,不是 4 个,也不是 2 个呢?
首先,从系统规模来讲,3 个人负责开发一个系统,系统的复杂度刚好达到每个人都能全面理解整个系统,又能够进行分工的粒度;如果是 2 个人开发一个系统,系统的复杂度不够,开发人员可能觉得无法体现自己的技术实力;如果是 4 个甚至更多人开发一个系统,系统复杂度又会无法让开发人员对系统的细节都了解很深。
其次,从团队管理来说,3 个人可以形成一个稳定的备份,即使 1 个人休假或者调配到其他系统,剩余 2 个人还可以支撑;如果是 2 个人,抽调 1 个后剩余的 1 个人压力很大;如果是 1 个人,这就是单点了,团队没有备份,某些情况下是很危险的,假如这个人休假了,系统出问题了怎么办?
最后,从技术提升的角度来讲,3 个人的技术小组既能够形成有效的讨论,又能够快速达成一致意见;如果是 2 个人,可能会出现互相坚持自己的意见,或者 2 个人经验都不足导致设计缺陷;如果是 1 个人,由于没有人跟他进行技术讨论,很可能陷入思维盲区导致重大问题;如果是 4 个人或者更多,可能有的参与的人员并没有认真参与,只是完成任务而已。
“三个火枪手”的原则主要应用于微服务设计和开发阶段,如果微服务经过一段时间发展后已经比较稳定,处于维护期了,无须太多的开发,那么平均 1 个人维护 1 个微服务甚至几个微服务都可以。当然考虑到人员备份问题,每个微服务最好都安排 2 个人维护,每个人都可以维护多个微服务。
拆分方法
基于“三个火枪手”的理论,可以计算出拆分后合适的服务数量,但具体怎么拆也是有技巧的,并不是快刀斩乱麻随便拆分成指定数量的微服务就可以了,也不是只能按照业务来进行拆分,而是可以根据目的的不同灵活地选取不同的拆分方式。接下来一一介绍常见的拆分方式。
基于业务逻辑拆分
这是最常见的一种拆分方式,将系统中的业务模块按照职责范围识别出来,每个单独的业务模块拆分为一个独立的服务。
基于业务逻辑拆分虽然看起来很直观,但在实践过程中最常见的一个问题就是团队成员对于“职责范围”的理解差异很大,经常会出现争论,难以达成一致意见。例如:假设做一个电商系统,第一种方式是将服务划分为“商品”“交易”“用户”3 个服务,第二种方式是划分为“商品”“订单”“支付”“发货”“买家”“卖家”6 个服务,哪种方式更合理,是不是划分越细越正确?
导致这种困惑的主要根因在于从业务的角度来拆分的话,规模粗和规模细都没有问题,因为拆分基础都是业务逻辑,要判断拆分粒度,不能从业务逻辑角度,而要根据前面介绍的“三个火枪手”的原则,计算一下大概的服务数量范围,然后再确定合适的“职责范围”,否则就可能出现划分过粗或者过细的情况,而且大部分情况下会出现过细的情况。
例如:如果团队规模是 10 个人支撑业务,按照“三个火枪手”规则计算,大约需要划分为 4 个服务,那么“登录、注册、用户信息管理”都可以划到“用户服务”职责范围内;如果团队规模是 100 人支撑业务,服务数量可以达到 40 个,那么“用户登录“就是一个服务了;如果团队规模达到 1000 人支撑业务,那“用户连接管理”可能就是一个独立的服务了。
基于可扩展拆分
将系统中的业务模块按照稳定性排序,将已经成熟和改动不大的服务拆分为稳定服务,将经常变化和迭代的服务拆分为变动服务。稳定的服务粒度可以粗一些,即使逻辑上没有强关联的服务,也可以放在同一个子系统中,例如将“日志服务”和“升级服务”放在同一个子系统中;不稳定的服务粒度可以细一些,但也不要太细,始终记住要控制服务的总数量。
这样拆分主要是为了提升项目快速迭代的效率,避免在开发的时候,不小心影响了已有的成熟功能导致线上问题。
基于可靠性拆分
将系统中的业务模块按照优先级排序,将可靠性要求高的核心服务和可靠性要求低的非核心服务拆分开来,然后重点保证核心服务的高可用。具体拆分的时候,核心服务可以是一个也可以是多个,只要最终的服务数量满足“三个火枪手”的原则就可以。
这样拆分带来下面几个好处:
1)避免非核心服务故障影响核心服务
例如,日志上报一般都属于非核心服务,但是在某些场景下可能有大量的日志上报,如果系统没有拆分,那么日志上报可能导致核心服务故障;拆分后即使日志上报有问题,也不会影响核心服务。
2)核心服务高可用方案可以更简单
核心服务的功能逻辑更加简单,存储的数据可能更少,用到的组件也会更少,设计高可用方案大部分情况下要比不拆分简单很多。
3)能够降低高可用成本
将核心服务拆分出来后,核心服务占用的机器、带宽等资源比不拆分要少很多。因此,只针对核心服务做高可用方案,机器、带宽等成本比不拆分要节省较多。
基于性能拆分
基于性能拆分和基于可靠性拆分类似,将性能要求高或者性能压力大的模块拆分出来,避免性能压力大的服务影响其他服务。常见的拆分方式和具体的性能瓶颈有关,可以拆分 Web 服务、数据库、缓存等。例如电商的抢购,性能压力最大的是入口的排队功能,可以将排队功能独立为一个服务。
以上几种拆分方式不是多选一,而是可以根据实际情况自由排列组合,例如可以基于可靠性拆分出服务 A,基于性能拆分出服务 B,基于可扩展拆分出 C/D/F 三个服务,加上原有的服务 X,最后总共拆分出 6 个服务(A/B/C/D/F/X)。
基础设施
大部分人主要关注的是微服务的“small”和“lightweight”特性,但实际上真正决定微服务成败的,恰恰是那个被大部分人都忽略的“automated”。为何这样说呢?因为服务粒度即使划分不合理,实际落地后如果团队遇到麻烦,自然会想到拆服务或者合服务;如果“automated”相关的基础设施不健全,那微服务就是焦油坑,让研发、测试、运维陷入各种微服务陷阱中。
微服务基础设施如下图所示:

看到上面这张图,相信很多人都会倒吸一口凉气,说好的微服务的“轻量级”呢?都这么多基础设施还好意思说自己是“轻量级”,感觉比 ESB 还要复杂啊?
确实如此,微服务并不是很多人认为的那样又简单又轻量级。要做好微服务,这些基础设施都是必不可少的,否则微服务就会变成一个焦油坑,让业务和团队在里面不断挣扎且无法自拔。因此也可以说,微服务并没有减少复杂度,而只是将复杂度从 ESB 转移到了基础设施。可以看到,“服务发现”“服务路由”等其实都是 ESB 的功能,只是在微服务中剥离出来成了独立的基础系统。
虽然建设完善的微服务基础设施是一项庞大的工程,但也不用太过灰心,认为自己团队小或者公司规模不大就不能实施微服务了。第一个原因是已经有开源的微服务基础设施全家桶了,例如大名鼎鼎的 Spring Cloud 项目,涵盖了服务发现、服务路由、网关、配置中心等功能;第二个原因是如果微服务的数量并不是很多的话,并不是每个基础设施都是必须的。通常情况下,建议按照下面优先级来搭建基础设施:
1)服务发现、服务路由、服务容错:这是最基本的微服务基础设施。
2)接口框架、API 网关:主要是为了提升开发效率,接口框架是提升内部服务的开发效率,API 网关是为了提升与外部服务对接的效率。
3)自动化部署、自动化测试、配置中心:主要是为了提升测试和运维效率。
4)服务监控、服务跟踪、服务安全:主要是为了进一步提升运维效率。
以上 3 和 4 两类基础设施,其重要性会随着微服务节点数量增加而越来越重要,但在微服务节点数量较少的时候,可以通过人工的方式支撑,虽然效率不高,但也基本能够顶住。
微服务架构最佳实践:基础设施篇
每项微服务基础设施都是一个平台、一个系统、一个解决方案,如果要自己实现,其过程和做业务系统类似,都需要经过需求分析、架构设计、开发、测试、部署上线等步骤,这里介绍一下每个基础设施的主要作用,更多详细设计可以参考 Spring Cloud 的相关资料(https://projects.spring.io/spring-cloud/)。
自动化测试
微服务将原本大一统的系统拆分为多个独立运行的“微”服务,微服务之间的接口数量大大增加,并且微服务提倡快速交付,版本周期短,版本更新频繁。如果每次更新都靠人工回归整个系统,则工作量大,效率低下,达不到“快速交付”的目的,因此必须通过自动化测试系统来完成绝大部分测试回归的工作。
自动化测试涵盖的范围包括代码级的单元测试、单个系统级的集成测试、系统间的接口测试,理想情况是每类测试都自动化。如果因为团队规模和人力的原因无法全面覆盖,至少要做到接口测试自动化。
自动化部署
相比大一统的系统,微服务需要部署的节点增加了几倍甚至十几倍,微服务部署的频率也会大幅提升(例如,业务系统 70% 的工作日都有部署操作),综合计算下来,微服务部署的次数是大一统系统部署次数的几十倍。这么大量的部署操作,如果继续采用人工手工处理,需要投入大量的人力,且容易出错,因此需要自动化部署的系统来完成部署操作。
自动化部署系统包括版本管理、资源管理(例如,机器管理、虚拟机管理)、部署操作、回退操作等功能。
配置中心
微服务的节点数量非常多,通过人工登录每台机器手工修改,效率低,容易出错。特别是在部署或者排障时,需要快速增删改查配置,人工操作的方式显然是不行的。除此以外,有的运行期配置需要动态修改并且所有节点即时生效,人工操作是无法做到的。综合上面的分析,微服务需要一个统一的配置中心来管理所有微服务节点的配置。
配置中心包括配置版本管理(例如,同样的微服务,有 10 个节点是给移动用户服务的,有 20 个节点给联通用户服务的,配置项都一样,配置值不一样)、增删改查配置、节点管理、配置同步、配置推送等功能。
接口框架
微服务提倡轻量级的通信方式,一般采用 HTTP/REST 或者 RPC 方式统一接口协议。但在实践过程中,光统一接口协议还不够,还需要统一接口传递的数据格式。例如,需要指定接口协议为 HTTP/REST,但这还不够,还需要指定 HTTP/REST 的数据格式采用 JSON,并且 JSON 的数据都遵循相应规范。
如果只是简单指定了 HTTP/REST 协议,而不指定 JSON 和 JSON 的数据规范,那么就会出现这样混乱的情况:有的微服务采用 XML,有的采用 JSON,有的采用键值对;即使同样都是 JSON,JSON 数据格式也不一样。这样每个微服务都要适配几套甚至几十套接口协议,相当于把曾经由 ESB 做的事情转交给微服务自己做了,这样做的效率显然是无法接受的,因此需要统一接口框架。
接口框架不是一个可运行的系统,一般以库或者包的形式提供给所有微服务调用。例如,针对上面的 JSON 样例,可以由某个基础技术团队提供多种不同语言的解析包(Java 包、Python 包、C 库等)。
API 网关
系统拆分为微服务后,内部的微服务之间是互联互通的,相互之间的访问都是点对点的。如果外部系统想调用系统的某个功能,也采取点对点的方式,则外部系统会非常“头大”。因为在外部系统看来,它不需要也没办法理解这么多微服务的职责分工和边界,它只会关注它需要的能力,而不会关注这个能力应该由哪个微服务提供。
除此以外,外部系统访问系统还涉及安全和权限相关的限制,如果外部系统直接访问某个微服务,则意味着每个微服务都要自己实现安全和权限的功能,这样做不但工作量大,而且都是重复工作。
综合上面的分析,微服务需要一个统一的 API 网关,负责外部系统的访问操作。API 网关是外部系统访问的接口,所有的外部系统接⼊系统都需要通过 API 网关,主要包括接入鉴权(是否允许接入)、权限控制(可以访问哪些功能)、传输加密、请求路由、流量控制等功能。
服务发现
微服务种类和数量很多,如果这些信息全部通过手工配置的方式写入各个微服务节点,首先配置工作量很大,配置文件可能要配几百上千行,几十个节点加起来后配置项就是几万几十万行了,人工维护这么大数量的配置项是一项灾难;其次是微服务节点经常变化,可能是由于扩容导致节点增加,也可能是故障处理时隔离掉一部分节点,还可能是采用灰度升级,先将一部分节点升级到新版本,然后让新老版本同时运行。不管哪种情况,都希望节点的变化能够及时同步到所有其他依赖的微服务。如果采用手工配置,是不可能做到实时更改生效的。因此,需要一套服务发现的系统来支撑微服务的自动注册和发现。
服务发现主要有两种实现方式:自理式和代理式。
自理式
自理式结构如下:

自理式结构就是指每个微服务自己完成服务发现。例如,图中 SERVICE INSTANCE A 访问 SERVICE REGISTRY 获取服务注册信息,然后直接访问 SERVICE INSTANCE B。
自理式服务发现实现比较简单,因为这部分的功能一般通过统一的程序库或者程序包提供给各个微服务调用,而不会每个微服务都自己来重复实现一遍;并且由于每个微服务都承担了服务发现的功能,访问压力分散到了各个微服务节点,性能和可用性上不存在明显的压力和风险。
代理式
代理式结构如下:

代理式结构就是指微服务之间有一个负载均衡系统(图中的 LOAD BALANCER 节点),由负载均衡系统来完成微服务之间的服务发现。
代理式的方式看起来更加清晰,微服务本身的实现也简单了很多,但实际上这个方案风险较大。第一个风险是可用性风险,一旦 LOAD BALANCER 系统故障,就会影响所有微服务之间的调用;第二个风险是性能风险,所有的微服务之间的调用流量都要经过 LOAD BALANCER 系统,性能压力会随着微服务数量和流量增加而不断增加,最后成为性能瓶颈。因此 LOAD BALANCER 系统需要设计成集群的模式,但 LOAD BALANCER 集群的实现本身又增加了复杂性。
不管是自理式还是代理式,服务发现的核心功能就是服务注册表,注册表记录了所有的服务节点的配置和状态,每个微服务启动后都需要将自己的信息注册到服务注册表,然后由微服务或者 LOAD BALANCER 系统到服务注册表查询可用服务。
服务路由
有了服务发现后,微服务之间能够方便地获取相关配置信息,但具体进行某次调用请求时,还需要从所有符合条件的可用微服务节点中挑选出一个具体的节点发起请求,这就是服务路由需要完成的功能。
服务路由和服务发现紧密相关,服务路由一般不会设计成一个独立运行的系统,通常情况下是和服务发现放在一起实现的。对于自理式服务发现,服务路由是微服务内部实现的;对于代理式服务发现,服务路由是由 LOAD BALANCER 系统实现的。无论放在哪里实现,服务路由核心的功能就是路由算法。常见的路由算法有:随机路由、轮询路由、最小压力路由、最小连接数路由等。
服务容错
系统拆分为微服务后,单个微服务故障的概率变小,故障影响范围也减少,但是微服务的节点数量大大增加。从整体上来看,系统中某个微服务出故障的概率会大大增加。前面在分析微服务陷阱时提到微服务具有故障扩散的特点,如果不及时处理故障,故障扩散开来就会导致看起来系统中很多服务节点都故障了,因此需要微服务能够自动应对这种出错场景,及时进行处理。否则,如果节点一故障就需要人工处理,投入人力大,处理速度慢;而一旦处理速度慢,则故障就很快扩散,所以我们需要服务容错的能力。
常见的服务容错包括请求重试、流控和服务隔离。通常情况下,服务容错会集成在服务发现和服务路由系统中。
服务监控
系统拆分为微服务后,节点数量大大增加,导致需要监控的机器、网络、进程、接口调用数等监控对象的数量大大增加;同时,一旦发生故障,需要快速根据各类信息来定位故障。这两个目标如果靠人力去完成是不现实的。举个简单例子:收到用户投诉说业务有问题,如果此时采取人工的方式去搜集、分析信息,可能把几十个节点的日志打开一遍就需要十几分钟了,因此需要服务监控系统来完成微服务节点的监控。
服务监控的主要作用有:
1)实时搜集信息并进行分析,避免故障后再来分析,减少了处理时间。
2)服务监控可以在实时分析的基础上进行预警,在问题萌芽的阶段发觉并预警,降低了问题影响的范围和时间。
通常情况下,服务监控需要搜集并分析大量的数据,因此建议做成独立的系统,而不要集成到服务发现、API 网关等系统中。
服务跟踪
服务监控可以做到微服务节点级的监控和信息收集,但如果需要跟踪某一个请求在微服务中的完整路径,服务监控是难以实现的。因为如果每个服务的完整请求链信息都实时发送给服务监控系统,数据量会大到无法处理。
服务监控和服务跟踪的区别可以简单概括为宏观和微观的区别。例如,A 服务通过 HTTP 协议请求 B 服务 10 次,B 通过 HTTP 返回 JSON 对象,服务监控会记录请求次数、响应时间平均值、响应时间最高值、错误码分布这些信息;而服务跟踪会记录其中某次请求的发起时间、响应时间、响应错误码、请求参数、返回的 JSON 对象等信息。
目前无论是分布式跟踪还是微服务的服务跟踪,绝大部分请求跟踪的实现技术都基于 Google 的 Dapper 论文《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》。
服务安全
系统拆分为微服务后,数据分散在各个微服务节点上。从系统连接的角度来说,任意微服务都可以访问所有其他微服务节点;但从业务的角度来说,部分敏感数据或者操作,只能部分微服务可以访问,而不是所有的微服务都可以访问,因此需要设计服务安全机制来保证业务和数据的安全性。
服务安全主要分为三部分:接入安全、数据安全、传输安全。通常情况下,服务安全可以集成到配置中心系统中进行实现,即配置中心配置微服务的接入安全策略和数据安全策略,微服务节点从配置中心获取这些配置信息,然后在处理具体的微服务调用请求时根据安全策略进行处理。由于这些策略是通用的,一般会把策略封装成通用的库提供给各个微服务调用。基本架构如下:

微内核架构详解
微内核架构(Microkernel Architecture),也被称为插件化架构(Plug-in Architecture),是一种面向功能进行拆分的可扩展性架构,通常用于实现基于产品(原文为 product-based,指存在多个版本、需要下载安装才能使用,与 web-based 相对应)的应用。例如 Eclipse 这类 IDE 软件、UNIX 这类操作系统、淘宝 App 这类客户端软件等,也有一些企业将自己的业务系统设计成微内核的架构,例如保险公司的保险核算逻辑系统,不同的保险品种可以将逻辑封装成插件。
基本架构
微内核架构包含两类组件:核心系统(core system)和插件模块(plug-in modules)。核心系统负责和具体业务功能无关的通用功能,例如模块加载、模块间通信等;插件模块负责实现具体的业务逻辑,例如专栏前面经常提到的“学生信息管理”系统中的“手机号注册”功能。
微内核的基本架构示意图如下:

上面这张图中核心系统 Core System 功能比较稳定,不会因为业务功能扩展而不断修改,插件模块可以根据业务功能的需要不断地扩展。微内核的架构本质就是将变化部分封装在插件里面,从而达到快速灵活扩展的目的,而又不影响整体系统的稳定。
设计关键点
微内核的核心系统设计的关键技术有:插件管理、插件连接和插件通信。
插件管理
核心系统需要知道当前有哪些插件可用,如何加载这些插件,什么时候加载插件。常见的实现方法是插件注册表机制。
核心系统提供插件注册表(可以是配置文件,也可以是代码,还可以是数据库),插件注册表含有每个插件模块的信息,包括它的名字、位置、加载时机(启动就加载,还是按需加载)等。
插件连接
插件连接指插件如何连接到核心系统。通常来说,核心系统必须制定插件和核心系统的连接规范,然后插件按照规范实现,核心系统按照规范加载即可。
常见的连接机制有 OSGi(Eclipse 使用)、消息模式、依赖注入(Spring 使用),甚至使用分布式的协议都是可以的,比如 RPC 或者 HTTP Web 的方式。
插件通信
插件通信指插件间的通信。虽然设计的时候插件间是完全解耦的,但实际业务运行过程中,必然会出现某个业务流程需要多个插件协作,这就要求两个插件间进行通信。由于插件之间没有直接联系,通信必须通过核心系统,因此核心系统需要提供插件通信机制。这种情况和计算机类似,计算机的 CPU、硬盘、内存、网卡是独立设计的配件,但计算机运行过程中,CPU 和内存、内存和硬盘肯定是有通信的,计算机通过主板上的总线提供了这些组件之间的通信功能。微内核的核心系统也必须提供类似的通信机制,各个插件之间才能进行正常的通信。
OSGi 架构简析
OSGi 的全称是 Open Services Gateway initiative,本身其实是指 OSGi Alliance。这个联盟是 Sun Microsystems、IBM、爱立信等公司于 1999 年 3 月成立的开放的标准化组织,最初名为 Connected Alliance。它是一个非盈利的国际组织,旨在建立一个开放的服务规范,为通过网络向设备提供服务建立开放的标准,这个标准就是 OSGi specification。现在我们谈到 OSGi,如果没有特别说明,一般都是指 OSGi 的规范。
OSGi 联盟的初始目标是构建一个在广域网和局域网或设备上展开业务的基础平台,所以 OSGi 的最早设计也是针对嵌入式应用的,诸如机顶盒、服务网关、手机、汽车等都是其应用的主要环境。然而,无心插柳柳成荫,由于 OSGi 具备动态化、热插拔、高可复用性、高效性、扩展方便等优点,它被应用到了 PC 上的应用开发。尤其是 Eclipse 这个流行软件采用 OSGi 标准后,OSGi 更是成为了首选的插件化标准。现在谈论 OSGi,已经和嵌入式应用关联不大了,更多是将 OSGi 当作一个微内核的架构模式。
Eclipse 从 3.0 版本开始,抛弃了原来自己实现的插件化框架,改用了 OSGi 框架。需要注意的是,OSGi 是一个插件化的标准,而不是一个可运行的框架,Eclipse 采用的 OSGi 框架称为 Equinox,类似的实现还有 Apache 的 Felix、Spring 的 Spring DM。OSGi 框架的逻辑架构图如下:

模板层(Module 层)
模块层实现插件管理功能。OSGi 中,插件被称为 Bundle,每个 Bundle 是一个 Java 的 JAR 文件,每个 Bundle 里面都包含一个元数据文件 MANIFEST.MF,这个文件包含了 Bundle 的基本信息。例如,Bundle 的名称、描述、开发商、classpath,以及需要导入的包和输出的包等,OSGi 核心系统会将这些信息加载到系统中用于后续使用。
生命周期层(Lifecycle 层)
生命周期层实现插件连接功能,提供了执行时模块管理、模块对底层 OSGi 框架的访问。生命周期层精确地定义了 Bundle 生命周期的操作(安装、更新、启动、停止、卸载),Bundle 必须按照规范实现各个操作。
服务层(Service 层)
服务层实现插件通信的功能。OSGi 提供了一个服务注册的功能,用于各个插件将自己能提供的服务注册到 OSGi 核心的服务注册中心,如果某个服务想用其他服务,则直接在服务注册中心搜索可用服务中心就可以了。
规则引擎架构简析
规则引擎从结构上来看也属于微内核架构的一种具体实现,其中执行引擎可以看作是微内核,执行引擎解析配置好的业务流,执行其中的条件和规则,通过这种方式来支持业务的灵活多变。
规则引擎在计费、保险、促销等业务领域应用较多。例如电商促销 满 100 送 50,3 件立减 50。促销规则完整列下来可能有几十上百种,再加上排列组合,促销方案可能有几百上千种,这样的业务如果完全靠代码来实现,开发效率远远跟不上业务的变化速度,而规则引擎却能够很灵活的应对这种需求,主要原因在于:
1)可扩展
通过引入规则引擎,业务逻辑实现与业务系统分离,可以在不改动业务系统的情况下扩展新的业务功能。
2)易理解
规则通过自然语言描述,业务人员易于理解和操作,而不像代码那样只有程序员才能理解和开发。
3)高效率
规则引擎系统一般提供可视化的规则定制、审批、查询及管理,方便业务人员快速配置新的业务。
规则引擎的基本架构如下:

简单介绍一下:
1)开发人员将业务功能分解提炼为多个规则,将规则保存在规则库中。
2)业务人员根据业务需要,通过将规则排列组合,配置成业务流程,保存在业务库中。
3)规则引擎执行业务流程实现业务功能。
对照微内核架构的设计关键点,规则引擎是具体是如何实现的。
1)插件管理
规则引擎中的规则就是微内核架构的插件,引擎就是微内核架构的内核。规则可以被引擎加载和执行。规则引擎架构中,规则一般保存在规则库中,通常使用数据库来存储。
2)插件连接
类似于程序员开发的时候需要采用 Java、C++ 等语言,规则引擎也规定了规则开发的语言,业务人员需要基于规则语言来编写规则文件,然后由规则引擎加载执行规则文件来完成业务功能,因此,规则引擎的插件连接实现机制其实就是规则语言。
3)插件通信
规则引擎的规则之间进行通信的方式就是数据流和事件流,由于单个规则并不需要依赖其他规则,因此规则之间没有主动的通信,规则只需要输出数据或者事件,由引擎将数据或者事件传递到下一个规则。
目前最常用的规则引擎是开源的 JBoss Drools,采用 Java 语言编写,基于 Rete 算法(参考https://en.wikipedia.org/wiki/Rete_algorithm)。Drools 具有下面这些优点:
1)非常活跃的社区支持,以及广泛的应用。
2)快速的执行速度。
3)与 Java Rule Engine API(JSR-94)兼容。
4)提供了基于 Web 的 BRMS——Guvnor,Guvnor 提供了规则管理的知识库,通过它可以实现规则的版本控制,以及规则的在线修改与编译,使得开发人员和系统管理人员可以在线管理业务规则。
虽然 Drools 号称简单易用,但实际上其规则语言还是和编程语言比较类似,在实际应用的时候普通业务人员面对这样的规则语言,学习成本和理解成本还是比较高的,例如下面这个样例(https://blog.csdn.net/ouyangshixiong/article/details/46315273):
因此,通常情况下需要基于 Drools 进行封装,将规则配置做成可视化的操作,例如下面电商反欺诈的一个示例(https://cloud.tencent.com/developer/article/1031839)
22.6 - 架构设计05-架构实战
架构演进方向
架构演进背景
互联网的出现不但改变了普通人的生活方式,同时也促进了技术圈的快速发展和开放。在开源和分享两股力量的推动下,最近 10 多年的技术发展可以说是目不暇接,你方唱罢我登场,大的方面有大数据、云计算、人工智能等,细分的领域有 NoSQL、Node.js、Docker 容器化等。各个大公司也乐于将自己的技术分享出来,以此来提升自己的技术影响力,打造圈内技术口碑,从而形成强大的人才吸引力,典型的有,Google 的大数据论文、淘宝的全链路压测、微信的红包高并发技术等。
对于技术人员来说,技术的快速发展当然是一件大好事,毕竟这意味着技术百宝箱中又多了更多的可选工具,同时也可以通过学习业界先进的技术来提升自己的技术实力。但对于架构师来说,除了这些好处,却也多了“甜蜜的烦恼”:面对层出不穷的新技术,应该采取什么样的策略?
架构师可能经常会面临下面这些诱惑或者挑战:
1)现在 Docker 虚拟化技术很流行,要不要引进,引入 Docker 后可以每年节省几十万元的硬件成本呢?
2)竞争对手用了阿里的云计算技术,听说因为上了云,业务增长了好几倍呢,我们是否也应该尽快上云啊?
3)自己的技术和业界顶尖公司(例如,淘宝、微信)差距很大,应该投入人力和时间追上去,不然招聘的时候没有技术影响力!
4)公司的技术发展现在已经比较成熟了,程序员都觉得在公司学不到东西,可以尝试引入 Golang 来给大家一个学习新技术的机会。
类似的问题还有很多,本质上都可以归纳总结为一个问题:架构师应该如何判断技术演进的方向?关于这个问题的答案,基本上可以分为几个典型的派别:
潮流派
潮流派的典型特征就是对于新技术特别热衷,紧跟技术潮流,当有新的技术出现时,迫切想将新的技术应用到自己的产品中。
例如:
1)NoSQL 很火,咱们要大规模地切换为 NoSQL。
2)大数据好牛呀,将我们的 MySQL 切换为 Hadoop 吧。
3)Node.js 使得 JavaScript 统一前后端,这样非常有助于开展工作。
问题:
首先,新技术需要时间成熟,如果刚出来就用,此时新技术还不怎么成熟,实际应用中很可能遇到各种“坑”,自己成了实验小白鼠。
其次,新技术需要学习,需要花费一定的时间去掌握,这个也是较大的成本;如果等到掌握了技术后又发现不适用,则是一种较大的人力浪费。
保守派
保守派的典型特征和潮流派正好相反,对于新技术抱有很强的戒备心,稳定压倒一切,已经掌握了某种技术,就一直用这种技术打天下。就像有句俗语说的,“如果你手里有一把锤子,那么所有的问题都变成了钉子”,保守派就是拿着一把锤子解决所有的问题。
例如:
1)MySQL 咱们用了这么久了,很熟悉了,业务用 MySQL,数据分析也用 MySQL,报表还用 MySQL 吧。
2)Java 语言我们都很熟,业务用 Java,工具用 Java,平台也用 Java。
问题:
保守派的主要问题是不能享受新技术带来的收益,因为新技术很多都是为了解决以前技术存在的固有缺陷。就像汽车取代马车一样,不是量变而是质变,带来的收益不是线性变化的,而是爆发式变化的。如果无视技术的发展,形象一点说就是有了拖拉机,你还偏偏要用牛车。
跟风派
跟风派与潮流派不同,这里的跟风派不是指跟着技术潮流,而是指跟着竞争对手的步子走。简单来说,判断技术的发展就看竞争对手,竞争对手用了咱们就用,竞争对手没用咱们就等等看。例如:
1)这项技术腾讯用了吗?腾讯用了我们就用。
2)阿里用了 Hadoop,他们都在用,肯定是好东西,咱们也要尽快用起来,以提高咱们的竞争力。
3)Google 都用了 Docker,咱们也用吧。
不同派别的不同做法本质上是价值观的不同:潮流派的价值观是新技术肯定能带来很大收益;稳定派的价值观是稳定压倒一切;跟风派的价值观是别人用了我就用。这些价值观本身都有一定的道理,但如果不考虑实际情况生搬硬套,就会出现“橘生淮南则为橘,生于淮北则为枳”的情况。
问题:
可能很多人都会认为,跟风派与“潮流派”和“保守派”相比,是最有效的策略,既不会承担“潮流派”的风险,也不会遭受“保守派”的损失,花费的资源也少,简直就是一举多得。
看起来很美妙,但跟风派最大的问题在于如果没有风可跟的时候怎么办。如果你是领头羊怎么办,其他人都准备跟你的风呢?另外一种情况就是竞争对手的这些信息并不那么容易获取,即使获取到了一些信息,大部分也是不全面的,一不小心可能就变成邯郸学步了。
即使有风可跟,其实也存在问题。有时候适用于竞争对手的技术,并不一定适用于自己,盲目模仿可能带来相反的效果。
既然潮流派、保守派、跟风派都存在这样或者那样的问题,那架构师究竟如何判断技术演进的方向呢?
技术演进的动力
不管是潮流派、保守派,还是跟风派,都是站在技术本身的角度来考虑问题的,正所谓“不识庐山真面,只缘身在此山中”,只有跳出技术的范畴,从一个更广更高的角度来考虑这个问题,这个角度就是企业的业务发展。
无论是代表新兴技术的互联网企业,还是代表传统技术的制造业;无论是通信行业,还是金融行业的发展,归根到底就是业务的发展。而影响一个企业业务的发展主要有 3 个因素:市场、技术、管理,这三者构成支撑业务发展的铁三角,任何一个因素的不足,都可能导致企业的业务停滞不前。

在这个铁三角中,业务处于三角形的中心,毫不夸张地说,市场、技术、管理都是为了支撑企业业务的发展。这里主要探讨“技术”和“业务”之间的关系和互相如何影响。
可以简单地将企业的业务分为两类:一类是产品类,一类是服务类。
产品类:360 的杀毒软件、苹果的 iPhone、UC 的浏览器等都属于这个范畴,这些产品本质上和传统的制造业产品类似,都是具备了某种“功能”,单个用户通过购买或者免费使用这些产品来完成自己相关的某些任务,用户对这些产品是独占的。
服务类:百度的搜索、淘宝的购物、新浪的微博、腾讯的 IM 等都属于这个范畴,大量用户使用这些服务来完成需要与其他人交互的任务,单个用户“使用”但不“独占”某个服务。事实上,服务的用户越多,服务的价值就越大。服务类的业务符合互联网的特征和本质:“互联”+“网”。
对于产品类业务,技术创新推动业务发展!
为何对于产品类的业务,技术创新能够推动业务发展呢?答案在于用户选择一个产品的根本驱动力在于产品的功能是否能够更好地帮助自己完成任务。用户会自然而然地选择那些功能更加强大、性能更加先进、体验更加顺畅、外观更加漂亮的产品,而功能、性能、体验、外观等都需要强大的技术支撑。例如,iPhone 手机的多点触摸操作、UC 浏览器的 U3 内核等。
对于“服务”类的业务,答案和产品类业务正好相反:业务发展推动技术的发展!
截然相反的主要原因是用户选择服务的根本驱动力与选择产品不同。用户选择一个产品的根本驱动力是其“功能”,而用户选择一个服务的根本驱动力不是功能,而是“规模”。
例如,选择 UC 浏览器还是选择 QQ 浏览器,更多的人是根据个人喜好和体验来决定的;而选择微信还是 Whatsapp,就不是根据它们之间的功能差异来选择的,而是根据其规模来选择的,就像我更喜欢 Whatsapp 的简洁,但我的朋友和周边的人都用微信,那我也不得不用微信。
当“规模”成为业务的决定因素后,服务模式的创新就成为了业务发展的核心驱动力,而产品只是为了完成服务而提供给用户使用的一个载体。以淘宝为例,淘宝提供的“网络购物”是一种新的服务,这种业务与传统的到实体店购物是完全不同的,而为了完成这种业务,需要“淘宝网”“支付宝”“一淘”和“菜鸟物流”等多个产品。随便一个软件公司,如果只是模仿开发出类似的产品,只要愿意投入,半年时间就可以将这些产品全部开发出来。但是这样做并没有意义,因为用户选择的是淘宝的整套网络购物服务,并且这个服务已经具备了一定的规模,其他公司不具备这种同等规模服务的能力。即使开发出完全一样的产品,用户也不会因为产品功能更加强大而选择新的类似产品。
以微信为例,同样可以得出类似结论。假如进行技术创新,开发一个耗电量只有微信的 1/10,用户体验比微信好 10 倍的产品,你觉得现在的微信用户都会抛弃微信,而转投我们的这个产品吗?我相信绝大部分人都不会,因为微信不是一个互联网产品,而是一个互联网服务,你一个人换到其他类微信类产品是没有意义的。
因此,服务类的业务发展路径是这样的:提出一种创新的服务模式→吸引了一批用户→业务开始发展→吸引了更多用户→服务模式不断完善和创新→吸引越来越多的用户,如此循环往复。在这个发展路径中,技术并没有成为业务发展的驱动力,反过来由于用户规模的不断扩展,业务的不断创新和改进,对技术会提出越来越高的要求,因此是业务驱动了技术发展。
其实回到产品类业务,如果将观察的时间拉长来看,即使是产品类业务,在技术创新开创了一个新的业务后,后续的业务发展也会反向推动技术的发展。例如,第一代 iPhone 缺少对 3G 的支持,且只能通过 Web 发布应用程序,第二代 iPhone 才开始支持 3G,并且内置 GPS;UC 浏览器随着功能越来越强大,原有的技术无法满足业务发展的需求,浏览器的架构需要进行更新,先后经过 UC 浏览器 7.0 版本、8.0 版本、9.0 版本等几个技术差异很大的版本。
综合这些分析,除非是开创新的技术能够推动或者创造一种新的业务,其他情况下,都是业务的发展推动了技术的发展。
技术演进的模式
明确了技术发展主要的驱动力是业务发展后,看看业务发展究竟是如何驱动技术发展的。
业务模式千差万别,有互联网的业务(淘宝、微信等),有金融的业务(中国平安、招商银行等),有传统企业的业务(各色 ERP 对应的业务)等,但无论什么模式的业务,如果业务的发展需要技术同步发展进行支撑,无一例外是因为业务“复杂度”的上升,导致原有的技术无法支撑。
按照前面所介绍的复杂度分类,复杂度要么来源于功能不断叠加,要么来源于规模扩大,从而对性能和可用性有了更高的要求。既然如此,判断到底是什么复杂度发生了变化就显得至关重要了。是任何时候都要同时考虑功能复杂度和规模复杂度吗?还是有时候考虑功能复杂度,有时候考虑规模复杂度?还是随机挑一个复杂度的问题解决就可以了?
对于架构师来说,判断业务当前和接下来一段时间的主要复杂度是什么就非常关键。判断不准确就会导致投入大量的人力和时间做了对业务没有作用的事情,判断准确就能够做到技术推动业务更加快速发展。那架构师具体应该按照什么标准来判断呢?
答案就是基于业务发展阶段进行判断,这也是为什么架构师必须具备业务理解能力的原因。不同的行业业务发展路径、轨迹、模式不一样,架构师必须能够基于行业发展和企业自身情况做出准确判断。
假设是一个银行 IT 系统的架构师:
1)90 年代主要的业务复杂度可能就是银行业务范围逐渐扩大,功能越来越复杂,导致内部系统数量越来越多,单个系统功能越来越复杂。
2)2004 年以后主要的复杂度就是银行业务从柜台转向网上银行,网上银行的稳定性、安全性、易用性是主要的复杂度,这些复杂度主要由银行 IT 系统自己解决。
3)2009 年以后主要的复杂度又变化为移动支付复杂度,尤其是“双 11”这种海量支付请求的情况下,高性能、稳定性、安全性是主要的复杂度,而这些复杂度需要银行和移动支付服务商(支付宝、微信)等一起解决。
而如果是淘宝这种互联网业务的架构师,业务发展又会是另外一种模式:
1)2003 年,业务刚刚创立,主要的复杂度体现为如何才能快速开发各种需求,淘宝团队采取的是买了一个 PHP 写的系统来改。
2)2004 年,上线后业务发展迅速,用户请求数量大大增加,主要的复杂度体现为如何才能保证系统的性能,淘宝的团队采取的是用 Oracle 取代 MySQL。
3)用户数量再次增加,主要的复杂度还是性能和稳定性,淘宝的团队采取的是 Java 替换 PHP。
4)2005 年,用户数量继续增加,主要的复杂度体现为单一的 Oracle 库已经无法满足性能要求,于是进行了分库分表、读写分离、缓存等优化。
5)2008 年,淘宝的商品数量在 1 亿以上,PV2.5 亿以上,主要的复杂度又变成了系统内部耦合,交易和商品耦合在一起,支付的时候又和支付宝强耦合,整个系统逻辑复杂,功能之间跳来跳去,用户体验也不好。淘宝的团队采取的是系统解耦,将交易中心、类目管理、用户中心从原来大一统的系统里面拆分出来。
互联网技术演进模式
各行业的业务发展轨迹并不完全相同,无法给出一个统一的模板让所有的架构师拿来就套用,因此以互联网的业务发展为案例,谈谈互联网技术演进的模式,其他行业可以参考分析方法对自己的行业进行分析。
互联网业务千差万别,但由于它们具有“规模决定一切”的相同点,其发展路径也基本上是一致的。互联网业务发展一般分为几个时期:初创期、发展期、竞争期、成熟期。
不同时期的差别主要体现在两个方面:复杂性、用户规模。
业务复杂性
互联网业务发展第一个主要方向就是“业务越来越复杂”,不同时期业务的复杂性的表现如下。
初创期
互联网业务刚开始一般都是一个创新的业务点,这个业务点的重点不在于“完善”,而在于“创新”,只有创新才能吸引用户;而且因为其“新”的特点,其实一开始是不可能很完善的。只有随着越来越多的用户的使用,通过快速迭代试错、用户的反馈等手段,不断地在实践中去完善,才能继续创新。初创期的业务对技术就一个要求:“快”,但这个时候却又是创业团队最弱小的时期,可能就几个技术人员,所以这个时候十八般武艺都需要用上:能买就买,有开源的就用开源的。
还以淘宝和 QQ 为例。
第一版的淘宝(https://blog.csdn.net/linlin_juejue/article/details/5959171)
第一版的 QQ(http://www.yixieshi.com/20770.html)
可以看到最开始的淘宝和 QQ 与现在相比,几乎看不出是同一个业务了。
发展期
当业务推出后经过市场验证如果是可行的,则吸引的用户就会越来越多,此时原来不完善的业务就进入了一个快速发展的时期。业务快速发展时期的主要目的是将原来不完善的业务逐渐完善,因此会有越来越多的新功能不断地加入到系统中。对于绝大部分技术团队来说,这个阶段技术的核心工作是快速地实现各种需求,只有这样才能满足业务发展的需要。
如何做到“快”,一般会经历下面几个阶段。
1、堆功能期
业务进入快速发展期的初期,此时团队规模也不大,业务需求又很紧,最快实现业务需求的方式是继续在原有的系统里面不断地增加新的功能,重构、优化、架构等方面的工作即使想做,也会受制于人力和业务发展的压力而放在一边。
2、优化期
“堆功能”的方式在刚开始的时候好用,因为系统还比较简单,但随着功能越来越多,系统开始变得越来越复杂,后面继续堆功能会感到越来越吃力,速度越来越慢。一种典型的场景是做一个需求要改好多地方,一不小心就改出了问题。直到有一天,技术团队或者产品人员再也受不了这种慢速的方式,终于下定决定要解决这个问题了。
如何解决这个问题,一般会分为两派:一派是优化派,一派是架构派。
优化派的核心思想是将现有的系统优化。例如,采用重构、分层、优化某个 MySQL 查询语句,将机械硬盘换成 SSD,将数据库从 MySQL 换成 Oracle,增加 Memcache 缓存等。优化派的优势是对系统改动较小,优化可以比较快速地实施;缺点就是可能过不了多久,系统又撑不住了。
架构派的核心思想是调整系统架构,主要是将原来的大系统拆分为多个互相配合的小系统。例如,将购物系统拆分为登录认证子系统、订单系统、查询系统、分析系统等。架构派的优势是一次调整可以支撑比较长期的业务发展,缺点是动作较大、耗时较长,对业务的发展影响也比较大。
相信在很多公司都遇到这种情况,大部分情况下都是“优化派”会赢,主要的原因还是因为此时“优化”是最快的方式。至于说“优化派”支撑不了多久这个问题,其实也不用考虑太多,因为业务能否发展到那个阶段还是个未知数,保证当下的竞争力是最主要的问题。
3、架构期
经过优化期后,如果业务能够继续发展,慢慢就会发现优化也顶不住了,毕竟再怎么优化,系统的能力总是有极限的。Oracle 再强大,也不可能一台 Oracle 顶住 1 亿的交易量;小型机再好,也不可能一台机器支持 100 万在线人数。此时已经没有别的选择,只能进行架构调整。
架构期可以用的手段很多,但归根结底可以总结为一个字“拆”,什么地方都可以拆。
拆功能:例如,将购物系统拆分为登录认证子系统、订单系统、查询系统、分析系统等。
拆数据库:MySQL 一台变两台,2 台变 4 台,增加 DBProxy、分库分表等。
拆服务器:服务器一台变两台,2 台变 4 台,增加负载均衡的系统,如 Nginx、HAProxy 等。
竞争期
当业务继续发展,已经形成一定规模后,一定会有竞争对手开始加入行业来竞争,毕竟谁都想分一块蛋糕,甚至有可能一不小心还会成为下一个 BAT。当竞争对手加入后,大家互相学习和模仿,业务更加完善,也不断有新的业务创新出来,而且由于竞争的压力,对技术的要求是更上一层楼了。
新业务的创新给技术带来的典型压力就是新的系统会更多,同时,原有的系统也会拆得越来越多。两者合力的一个典型后果就是系统数量在原来的基础上又增加了很多。架构拆分后带来的美好时光又开始慢慢消逝,技术工作又开始进入了“慢”的状态,这又是怎么回事呢?
原来系统数量越来越多,到了一个临界点后就产生了质变,即系统数量的量变带来了技术工作的质变。主要体现在下面几个方面:
1、重复造轮子
系统越来越多,各系统相似的工作越来越多。例如,每个系统都有存储,都要用缓存,都要用数据库。新建一个系统,这些工作又要都做一遍,即使其他系统已经做过了一遍,这样怎么能快得起来?
2、系统交互一团乱麻
系统越来越多,各系统的交互关系变成了网状。系统间的交互数量和系统的数量成平方比的关系。例如,4 个系统的交互路径是 6 个,10 个系统的交互路径是 45 个。每实现一个业务需求,都需要几个甚至十几个系统一起改,然后互相调用来调用去,联调成了研发人员的灾难、联测成了测试人员的灾难、部署成了运维的灾难。
针对这个时期业务变化带来的问题,技术工作主要的解决手段有:
1、平台化
目的在于解决“重复造轮子”的问题。
存储平台化:淘宝的 TFS、京东 JFS。
数据库平台化:百度的 DBProxy、淘宝 TDDL。
缓存平台化:Twitter 的 Twemproxy,豆瓣的 BeansDB、腾讯 TTC。
2、服务化
目的在于解决“系统交互”的问题,常见的做法是通过消息队列来完成系统间的异步通知,通过服务框架来完成系统间的同步调用。
消息队列:淘宝的 Notify、MetaQ,开源的 Kafka、ActiveMQ 等。
服务框架:Facebook 的 thrift、当当网的 Dubbox、淘宝的 HSF 等。
成熟期
当企业熬过竞争期,成为了行业的领头羊,或者整个行业整体上已经处于比较成熟的阶段,市场地位已经比较牢固后,业务创新的机会已经不大,竞争压力也没有那么激烈,此时求快求新已经没有很大空间,业务上开始转向为“求精”:响应时间是否比竞争对手快?用户体验是否比竞争对手好?成本是否比竞争对手低……
此时技术上其实也基本进入了成熟期,该拆的也拆了,该平台化的也平台化了,技术上能做的大动作其实也不多了,更多的是进行优化。但有时候也会为了满足某个优化,系统做很大的改变。例如,为了将用户响应时间从 200ms 降低到 50ms,可能就需要从很多方面进行优化:CDN、数据库、网络等。这个时候的技术优化没有固定的套路,只能按照竞争的要求,找出自己的弱项,然后逐项优化。在逐项优化时,可以采取之前各个时期采用的手段。
用户规模
互联网业务的发展第二个主要方向就是“用户量越来越大”。互联网业务的发展会经历“初创期、发展期、竞争期、成熟期”几个阶段,不同阶段典型的差别就是用户量的差别,用户量随着业务的发展而越来越大。用户量增大对技术的影响主要体现在两个方面:性能要求越来越高、可用性要求越来越高。
性能
用户量增大给技术带来的第一个挑战就是性能要求越来越高。以互联网企业最常用的 MySQL 为例,再简单的查询,再高的硬件配置,单台 MySQL 机器支撑的 TPS 和 QPS 最高也就是万级,低的可能是几千,高的也不过几万。当用户量增长后,必然要考虑使用多台 MySQL,从一台 MySQL 到多台 MySQL 不是简单的数量的增加,而是本质上的改变,即原来集中式的存储变为了分布式的存储。
稍微有经验的工程师都会知道,分布式将会带来复杂度的大幅度上升。以 MySQL 为例,分布式 MySQL 要考虑分库分表、读写分离、复制、同步等很多问题。
可用性
用户量增大对技术带来的第二个挑战就是可用性要求越来越高。当有 1 万个用户的时候,宕机 1 小时可能也没有很大的影响;但当有了 100 万用户的时候,宕机 10 分钟,投诉电话估计就被打爆了,这些用户再到朋友圈抱怨一下系统有多烂,很可能就不会再有机会发展下一个 100 万用户了。
除了口碑的影响,可用性对收入的影响也会随着用户量增大而增大。1 万用户宕机 1 小时,可能才损失了几千元;100 万用户宕机 10 分钟,损失可能就是几十万元了。
量变到质变
通过前面的分析可以看到互联网业务驱动技术发展的两大主要因素是复杂性和用户规模,而这两个因素的本质其实都是“量变带来质变”。
究竟用户规模发展到什么阶段才会由量变带来质变,虽然不同的业务有所差别,但基本上可以按照下面这个模型去衡量。

应对业务质变带来的技术压力,不同时期有不同的处理方式,但不管什么样的方式,其核心目标都是为了满足业务“快”的要求,当发现业务快不起来的时候,其实就是技术的水平已经跟不上业务发展的需要了,技术变革和发展的时候就到了。更好的做法是在问题还没有真正暴露出来就能够根据趋势预测下一个转折点,提前做好技术上的准备,这对技术人员的要求是非常高的。
互联网架构模板:存储层技术
很多人对于 BAT 的技术有一种莫名的崇拜感,觉得只有天才才能做出这样的系统,但经过前面对架构的本质、架构的设计原则、架构的设计模式、架构演进等多方位的探讨和阐述,可以看到,其实并没有什么神秘的力量和魔力融合在技术里面,而是业务的不断发展推动了技术的发展,这样一步一个脚印,持续几年甚至十几年的发展,才能达到当前技术复杂度和先进性。
抛开 BAT 各自差异很大的业务,站在技术的角度来看,其实 BAT 的技术架构基本是一样的。再将视角放大,会发现整个互联网行业的技术发展,最后都是殊途同归。
如果自己正处于一个创业公司,或者正在为成为另一个 BAT 拼搏,那么深入理解这种技术模式(或者叫技术结构、技术架构),对于自己和公司的发展都大有裨益。互联网的标准技术架构如下图所示,这张图基本上涵盖了互联网技术公司的大部分技术点,不同的公司只是在具体的技术实现上稍有差异,但不会跳出这个框架的范畴。

这里将逐层介绍每个技术点的产生背景、应用场景、关键技术,有的技术点可能已经在前面的架构模式部分有所涉及,因此就不再详细展开技术细节了,而是将关键技术点分门别类,进而形成一张架构大图,让架构师对一个公司的整体技术架构有一个完整的全貌认知。
SQL
SQL 即我们通常所说的关系数据。前几年 NoSQL 火了一阵子,很多人都理解为 NoSQL 是完全抛弃关系数据,全部采用非关系型数据。但经过几年的试验后,大家发现关系数据不可能完全被抛弃,NoSQL 不是 No SQL,而是 Not Only SQL,即 NoSQL 是 SQL 的补充。
所以互联网行业也必须依赖关系数据,考虑到 Oracle 太贵,还需要专人维护,一般情况下互联网行业都是用 MySQL、PostgreSQL 这类开源数据库。这类数据库的特点是开源免费,拿来就用;但缺点是性能相比商业数据库要差一些。随着互联网业务的发展,性能要求越来越高,必然要面对一个问题:将数据拆分到多个数据库实例才能满足业务的性能需求(其实 Oracle 也一样,只是时间早晚的问题)。
数据库拆分满足了性能的要求,但带来了复杂度的问题:数据如何拆分、数据如何组合?这个复杂度的问题解决起来并不容易,如果每个业务都去实现一遍,重复造轮子将导致投入浪费、效率降低,业务开发想快都快不起来。
所以互联网公司流行的做法是业务发展到一定阶段后,就会将这部分功能独立成中间件,例如百度的 DBProxy、淘宝的 TDDL。不过这部分的技术要求很高,将分库分表做到自动化和平台化,不是一件容易的事情,所以一般是规模很大的公司才会自己做。中小公司建议使用开源方案,例如 MySQL 官方推荐的 MySQL Router、360 开源的数据库中间件 Atlas。
假如公司业务继续发展,规模继续扩大,SQL 服务器越来越多,如果每个业务都基于统一的数据库中间件独立部署自己的 SQL 集群,就会导致新的复杂度问题,具体表现在:
1)数据库资源使用率不高,比较浪费。
2)各 SQL 集群分开维护,投入的维护成本越来越高。
因此,实力雄厚的大公司此时一般都会在 SQL 集群上构建 SQL 存储平台,以对业务透明的形式提供资源分配、数据备份、迁移、容灾、读写分离、分库分表等一系列服务,例如淘宝的 UMP(Unified MySQL Platform)系统。
NoSQL
首先 NoSQL 在数据结构上与传统的 SQL 的不同,例如典型的 Memcache 的 key-value 结构、Redis 的复杂数据结构、MongoDB 的文档数据结构;其次,NoSQL 无一例外地都会将性能作为自己的一大卖点。NoSQL 的这两个特点很好地弥补了关系数据库的不足,因此在互联网行业 NoSQL 的应用基本上是基础要求。
由于 NoSQL 方案一般自己本身就提供集群的功能,例如 Memcache 的一致性 Hash 集群、Redis 3.0 的集群,因此 NoSQL 在刚开始应用时很方便,不像 SQL 分库分表那么复杂。一般公司也不会在开始时就考虑将 NoSQL 包装成存储平台,但如果公司发展很快,例如 Memcache 的节点有上千甚至几千时,NoSQL 存储平台就很有意义了。首先是存储平台通过集中管理能够大大提升运维效率;其次是存储平台可以大大提升资源利用效率,2000 台机器,如果利用率能提升 10%,就可以减少 200 台机器,一年几十万元就节省出来了。
所以,NoSQL 发展到一定规模后,通常都会在 NoSQL 集群的基础之上再实现统一存储平台,统一存储平台主要实现这几个功能:
1)资源动态按需动态分配:例如同一台 Memcache 服务器,可以根据内存利用率,分配给多个业务使用。
2)资源自动化管理:例如新业务只需要申请多少 Memcache 缓存空间就可以了,无需关注具体是哪些 Memcache 服务器在为自己提供服务。
3)故障自动化处理:例如某台 Memcache 服务器挂掉后,有另外一台备份 Memcache 服务器能立刻接管缓存请求,不会导致丢失很多缓存数据。
当然要发展到这个阶段,一般也是大公司才会这么做,简单来说就是如果只有几十台 NoSQL 服务器,做存储平台收益不大;但如果有几千台 NoSQL 服务器,NoSQL 存储平台就能够产生很大的收益。
小文件存储
除了关系型的业务数据,互联网行业还有很多用于展示的数据。例如,淘宝的商品图片、商品描述;Facebook 的用户图片;新浪微博的一条微博内容等。这些数据具有三个典型特征:一是数据小,一般在 1MB 以下;二是数量巨大,Facebook 在 2013 年每天上传的照片就达到了 3.5 亿张;三是访问量巨大,Facebook 每天的访问量超过 10 亿。
由于互联网行业基本上每个业务都会有大量的小数据,如果每个业务都自己去考虑如何设计海量存储和海量访问,效率自然会低,重复造轮子也会投入浪费,所以自然而然就要将小文件存储做成统一的和业务无关的平台。
和 SQL 和 NoSQL 不同的是,小文件存储不一定需要公司或者业务规模很大,基本上认为业务在起步阶段就可以考虑做小文件统一存储。得益于开源运动的发展和最近几年大数据的火爆,在开源方案的基础上封装一个小文件存储平台并不是太难的事情。例如,HBase、Hadoop、Hypertable、FastDFS 等都可以作为小文件存储的底层平台,只需要将这些开源方案再包装一下基本上就可以用了。
典型的小文件存储有:淘宝的 TFS、京东 JFS、Facebook 的 Haystack。
下图是淘宝 TFS 的架构:

大文件存储
互联网行业的大文件主要分为两类:一类是业务上的大数据,例如 Youtube 的视频、电影网站的电影;另一类是海量的日志数据,例如各种访问日志、操作日志、用户轨迹日志等。和小文件的特点正好相反,大文件的数量没有小文件那么多,但每个文件都很大,几百 MB、几个 GB 都是常见的,几十 GB、几 TB 也是有可能的,因此在存储上和小文件有较大差别,不能直接将小文件存储系统拿来存储大文件。
说到大文件,特别要提到 Google 和 Yahoo,Google 的 3 篇大数据论文(Bigtable/Map- Reduce/GFS)开启了一个大数据的时代,而 Yahoo 开源的 Hadoop 系列(HDFS、HBase 等),基本上垄断了开源界的大数据处理。当然,江山代有才人出,长江后浪推前浪,Hadoop 后又有更多优秀的开源方案被贡献出来,现在随便走到大街上拉住一个程序员,如果他不知道大数据,那基本上可以确定是“火星程序员”。
对照 Google 的论文构建一套完整的大数据处理方案的难度和成本实在太高,而且开源方案现在也很成熟了,所以大数据存储和处理这块反而是最简单的,因为你没有太多选择,只能用这几个流行的开源方案,例如,Hadoop、HBase、Storm、Hive 等。实力雄厚一些的大公司会基于这些开源方案,结合自己的业务特点,封装成大数据平台,例如淘宝的云梯系统、腾讯的 TDW 系统。
下面是 Hadoop 的生态圈:

互联网架构模板:开发层和服务层技术
开发层技术
开发框架
前面深入分析了互联网业务发展的一个特点:复杂度越来越高。复杂度增加的典型现象就是系统越来越多,不同的系统由不同的小组开发。如果每个小组用不同的开发框架和技术,则会带来很多问题,典型的问题有:
1)技术人员之间没有共同的技术语言,交流合作少。
2)每类技术都需要投入大量的人力和资源并熟练精通。
3)不同团队之间人员无法快速流动,人力资源不能高效的利用。
所以,互联网公司都会指定一个大的技术方向,然后使用统一的开发框架。例如,Java 相关的开发框架 SSH、SpringMVC、Play,Ruby 的 Ruby on Rails,PHP 的 ThinkPHP,Python 的 Django 等。使用统一的开发框架能够解决上面提到的各种问题,大大提升组织和团队的开发效率。
对于框架的选择,有一个总的原则:优选成熟的框架,避免盲目追逐新技术!
原因如下:首先,成熟的框架资料文档齐备,各种坑基本上都有人踩过了,遇到问题很容易通过搜索来解决。其次,成熟的框架受众更广,招聘时更加容易招到合适的人才。第三,成熟的框架更加稳定,不会出现大的变动,适合长期发展。
Web 服务器
开发框架只是负责完成业务功能的开发,真正能够运行起来给用户提供服务,还需要服务器配合。
独立开发一个成熟的 Web 服务器,成本非常高,况且业界又有那么多成熟的开源 Web 服务器,所以互联网行业基本上都是“拿来主义”,挑选一个流行的开源服务器即可。大一点的公司,可能会在开源服务器的基础上,结合自己的业务特点做二次开发,例如淘宝的 Tengine,但一般公司基本上只需要将开源服务器摸透,优化一下参数,调整一下配置就差不多了。
选择一个服务器主要和开发语言相关,例如,Java 的有 Tomcat、JBoss、Resin 等,PHP/Python 的用 Nginx,当然最保险的就是用 Apache 了,什么语言都支持。
你可能会担心 Apache 的性能之类的问题,其实不用过早担心这个,等到业务真的发展到 Apache 撑不住的时候再考虑切换也不迟,那时候有的是钱,有的是人,有的是时间。
容器
容器是最近几年才开始火起来的,其中以 Docker 为代表,在 BAT 级别的公司已经有较多的应用。例如,腾讯万台规模的 Docker 应用实践(http://www.infoq.com/cn/articles/tencent-millions-scale-docker-application-practice)、新浪微博红包的大规模 Docker 集群(http://www.infoq.com/cn/articles/large-scale-docker-cluster-practise-experience-share)等。
传统的虚拟化技术是虚拟机,解决了跨平台的问题,但由于虚拟机太庞大,启动又慢,运行时太占资源,在互联网行业并没有大规模应用;而 Docker 的容器技术,虽然没有跨平台,但启动快,几乎不占资源,推出后立刻就火起来了,预计 Docker 类的容器技术将是技术发展的主流方向。
千万不要以为 Docker 只是一个虚拟化或者容器技术,它将在很大程度上改变目前的技术形势:
1)运维方式会发生革命性的变化:Docker 启动快,几乎不占资源,随时启动和停止,基于 Docker 打造自动化运维、智能化运维将成为主流方式。
2)设计模式会发生本质上的变化:启动一个新的容器实例代价如此低,将鼓励设计思路朝“微服务”的方向发展。
例如,一个传统的网站包括登录注册、页面访问、搜索等功能,没有用容器的情况下,除非有特别大的访问量,否则这些功能开始时都是集成在一个系统里面的;有了容器技术后,一开始就可以将这些功能按照服务的方式设计,避免后续访问量增大时又要重构系统。
服务层技术
互联网业务的不断发展带来了复杂度的不断提升,业务系统也越来越多,系统间相互依赖程度加深。比如说为了完成 A 业务系统,可能需要 B、C、D、E 等十几个其他系统进行合作。从数学的角度进行评估,可以发现系统间的依赖是呈指数级增长的:3 个系统相互关联的路径为 3 条,6 个系统相互关联的路径为 15 条。
服务层的主要目标其实就是为了降低系统间相互关联的复杂度。
配置中心
故名思议,配置中心就是集中管理各个系统的配置。当系统数量不多的时候,一般是各系统自己管理自己的配置,但系统数量多了以后,这样的处理方式会有问题:
1)某个功能上线时,需要多个系统配合一起上线,分散配置时,配置检查、沟通协调需要耗费较多时间。
2)处理线上问题时,需要多个系统配合查询相关信息,分散配置时,操作效率很低,沟通协调也需要耗费较多时间。
3)各系统自己管理配置时,一般是通过文本编辑的方式修改的,没有自动的校验机制,容易配置错误,而且很难发现。
例如,将 IP 地址的数字 0 误敲成了键盘的字母 O,肉眼非常难发现,但程序检查其实就很容易。
实现配置中心主要就是为了解决上面这些问题,将配置中心做成通用的系统的好处有:
1)集中配置多个系统,操作效率高。
2)所有配置都在一个集中的地方,检查方便,协作效率高。
3)配置中心可以实现程序化的规则检查,避免常见的错误。比如说检查最小值、最大值、是否 IP 地址、是否 URL 地址,都可以用正则表达式完成。
4)配置中心相当于备份了系统的配置,当某些情况下需要搭建新的环境时,能够快速搭建环境和恢复业务。
整机磁盘坏掉、机器主板坏掉……遇到这些不可恢复的故障时,基本上只能重新搭建新的环境。程序包肯定是已经有的,加上配置中心的配置,能够很快搭建新的运行环境,恢复业务。否则几十个配置文件重新一个个去 Vim 中修改,耗时很长,还很容易出错。
下面是配置中心简单的设计,其中通过“系统标识 + host + port”来标识唯一一个系统运行实例是常见的设计方法。

服务中心
当系统数量不多的时候,系统间的调用一般都是直接通过配置文件记录在各系统内部的,但当系统数量多了以后,这种方式就存在问题了。
比如说总共有 10 个系统依赖 A 系统的 X 接口,A 系统实现了一个新接口 Y,能够更好地提供原有 X 接口的功能,如果要让已有的 10 个系统都切换到 Y 接口,则这 10 个系统的几十上百台机器的配置都要修改,然后重启,可想而知这个效率是很低的。
除此以外,如果 A 系统总共有 20 台机器,现在其中 5 台出故障了,其他系统如果是通过域名访问 A 系统,则域名缓存失效前,还是可能访问到这 5 台故障机器的;如果其他系统通过 IP 访问 A 系统,那么 A 系统每次增加或者删除机器,其他所有 10 个系统的几十上百台机器都要同步修改,这样的协调工作量也是非常大的。
服务中心就是为了解决上面提到的跨系统依赖的“配置”和“调度”问题。服务中心的实现一般来说有两种方式:服务名字系统和服务总线系统。
1、服务名字系统(Service Name System)
看到这个翻译,会立刻联想到 DNS,即 Domain Name System。两者的性质是基本类似的。DNS 的作用将域名解析为 IP 地址,人工记不住太多的数字 IP,域名就容易记住。服务名字系统是为了将 Service 名称解析为“host + port + 接口名称”,但是和 DNS 一样,真正发起请求的还是请求方。基本的设计如下:

2、服务总线系统(Service Bus System)
可以联想到计算机的总线,两者的本质也是基本类似的。相比服务名字系统,服务总线系统更进一步了:由总线系统完成调用,服务请求方都不需要直接和服务提供方交互了。基本的设计如下:

“服务名字系统”和“服务总线系统”简单对比如下表所示:

消息队列
互联网业务的一个特点是“快”,这就要求很多业务处理采用异步的方式。例如,大 V 发布一条微博后,系统需要发消息给关注的用户,我们不可能等到所有消息都发送给关注用户后再告诉大 V 说微博发布成功了,只能先让大 V 发布微博,然后再发消息给关注用户。
传统的异步通知方式是由消息生产者直接调用消息消费者提供的接口进行通知的,但当业务变得庞大,子系统数量增多时,这样做会导致系统间交互非常复杂和难以管理,因为系统间互相依赖和调用,整个系统的结构就像一张蜘蛛网,如下图所示:

消息队列就是为了实现这种跨系统异步通知的中间件系统。消息队列既可以“一对一”通知,也可以“一对多”广播。以微博为例,可以清晰地看到异步通知的实现和作用,如下图所示。

对比前面的蜘蛛网架构,可以清晰地看出引入消息队列系统后的效果:
1)整体结构从网状结构变为线性结构,结构清晰;
2)消息生产和消息消费解耦,实现简单;
3)增加新的消息消费者,消息生产者完全不需要任何改动,扩展方便;
4)消息队列系统可以做高可用、高性能,避免各业务子系统各自独立做一套,减轻工作量;
5)业务子系统只需要聚焦业务即可,实现简单。
消息队列系统基本功能的实现比较简单,但要做到高性能、高可用、消息时序性、消息事务性则比较难。业界已经有很多成熟的开源实现方案,如果要求不高,基本上拿来用即可,例如,RocketMQ、Kafka、ActiveMQ 等。但如果业务对消息的可靠性、时序、事务性要求较高时,则要深入研究这些开源方案,否则很容易踩坑。
开源的用起来方便,但要改就很麻烦了。由于其相对比较简单,很多公司也会花费人力和时间重复造一个轮子,这样也有好处,因为可以根据自己的业务特点做快速的适配开发。
互联网架构模板:网络层技术
除了复杂度,互联网业务发展的另外两个关键特点是“高性能”和“高可用”。通常情况下,在设计高可用和高性能系统的时候,主要关注点在系统本身的复杂度,然后通过各种手段来实现高可用和高性能的要求,例如前面介绍的计算高性能架构模式、存储高可用架构模式等。但是当站在一个公司的的角度来思考架构的时候,单个系统的高可用和高性能并不等于整体业务的高可用和高性能,互联网业务的高性能和高可用需要从更高的角度去设计,这个高点就是“网络”,所以将相关措施统一划归为“网络层”。注意这里的网络层和通常理解的如何搭建一个局域网这种概念不一样,这里强调的是站在网络层的角度整体设计架构,而不是某个具体网络的搭建。
负载均衡
顾名思议,负载均衡就是将请求均衡地分配到多个系统上。使用负载均衡的原因也很简单:每个系统的处理能力是有限的,为了应对大容量的访问,必须使用多个系统。例如,一台 32 核 64GB 内存的机器,性能测试数据显示每秒处理 Hello World 的 HTTP 请求不超过 2 万,实际业务机器处理 HTTP 请求每秒可能才几百 QPS,而互联网业务并发超过 1 万是比较常见的,遇到双十一、过年发红包这些极端场景,每秒可以达到几十万的请求。
DNS
DNS 是最简单也是最常见的负载均衡方式,一般用来实现地理级别的均衡。例如,北方的用户访问北京的机房,南方的用户访问广州的机房。一般不会使用 DNS 来做机器级别的负载均衡,因为太耗费 IP 资源了。例如,百度搜索可能要 10000 台以上机器,不可能将这么多机器全部配置公网 IP,然后用 DNS 来做负载均衡。有兴趣的读者可以在 Linux 用“dig baidu.com”命令看看实际上用了几个 IP 地址。
DNS 负载均衡的优点是通用(全球通用)、成本低(申请域名,注册 DNS 即可),但缺点也比较明显,主要体现在:
1)DNS 缓存的时间比较长,即使将某台业务机器从 DNS 服务器上删除,由于缓存的原因,还是有很多用户会继续访问已经被删除的机器。
2)DNS 不够灵活。DNS 不能感知后端服务器的状态,只能根据配置策略进行负载均衡,无法做到更加灵活的负载均衡策略。比如说某台机器的配置比其他机器要好很多,理论上来说应该多分配一些请求给它,但 DNS 无法做到这一点。
所以对于时延和故障敏感的业务,有实力的公司可能会尝试实现 HTTP-DNS 的功能,即使用 HTTP 协议实现一个私有的 DNS 系统。HTTP-DNS 主要应用在通过 App 提供服务的业务上,因为在 App 端可以实现灵活的服务器访问策略,如果是 Web 业务,实现起来就比较麻烦一些,因为 URL 的解析是由浏览器来完成的,只有 Javascript 的访问可以像 App 那样实现比较灵活的控制。
HTTP-DNS 的优缺点有:
1)灵活:HTTP-DNS 可以根据业务需求灵活的设置各种策略。
2)可控:HTTP-DNS 是自己开发的系统,IP 更新、策略更新等无需依赖外部服务商。
3)及时:HTTP-DNS 不受传统 DNS 缓存的影响,可以非常快地更新数据、隔离故障。
4)开发成本高:没有通用的解决方案,需要自己开发。
5)侵入性:需要 App 基于 HTTP-DNS 进行改造。
Nginx 、LVS 、F5
DNS 用于实现地理级别的负载均衡,而 Nginx、LVS、F5 用于同一地点内机器级别的负载均衡。其中 Nginx 是软件的 7 层负载均衡,LVS 是内核的 4 层负载均衡,F5 是硬件的 4 层负载均衡。
软件和硬件的区别就在于性能,硬件远远高于软件,Ngxin 的性能是万级,一般的 Linux 服务器上装个 Nginx 大概能到 5 万 / 秒;LVS 的性能是十万级,没有具体测试过,据说可达到 80 万 / 秒;F5 性能是百万级,从 200 万 / 秒到 800 万 / 秒都有。硬件虽然性能高,但是单台硬件的成本也很高,一台最便宜的 F5 都是几十万,但是如果按照同等请求量级来计算成本的话,实际上硬件负载均衡设备可能会更便宜,例如假设每秒处理 100 万请求,用一台 F5 就够了,但用 Nginx,可能要 20 台,这样折算下来用 F5 的成本反而低。因此通常情况下,如果性能要求不高,可以用软件负载均衡;如果性能要求很高,推荐用硬件负载均衡。
4 层和 7 层的区别就在于协议和灵活性。Nginx 支持 HTTP、E-mail 协议,而 LVS 和 F5 是 4 层负载均衡,和协议无关,几乎所有应用都可以做,例如聊天、数据库等。
目前很多云服务商都已经提供了负载均衡的产品,例如阿里云的 SLB、UCloud 的 ULB 等,中小公司直接购买即可。
CDN
CDN 是为了解决用户网络访问时的“最后一公里”效应,本质上是一种“以空间换时间”的加速策略,即将内容缓存在离用户最近的地方,用户访问的是缓存的内容,而不是站点实时的内容。
下面是简单的 CDN 请求流程示意图:

CDN 经过多年的发展,已经变成了一个很庞大的体系:分布式存储、全局负载均衡、网络重定向、流量控制等都属于 CDN 的范畴,尤其是在视频、直播等领域,如果没有 CDN,用户是不可能实现流畅观看内容的。
幸运的是大部分程序员和架构师都不太需要深入理解 CDN 的细节,因为 CDN 作为网络的基础服务,独立搭建的成本巨大,很少有公司自己设计和搭建 CDN 系统,从 CDN 服务商购买 CDN 服务即可,目前有专门的 CDN 服务商,例如网宿和蓝汛;也有云计算厂家提供 CDN 服务,例如阿里云和腾讯云都提供 CDN 的服务。
多机房
从架构上来说,单机房就是一个全局的网络单点,在发生比较大的故障或者灾害时,单机房难以保证业务的高可用。例如,停电、机房网络中断、地震、水灾等都有可能导致一个机房完全瘫痪。
多机房设计最核心的因素就是如何处理时延带来的影响,常见的策略有:
同城多机房
同一个城市多个机房,距离不会太远,可以投入重金,搭建私有的高速网络,基本上能够做到和同机房一样的效果。
这种方式对业务影响很小,但投入较大,如果不是大公司,一般是承受不起的;而且遇到极端的地震、水灾等自然灾害,同城多机房也是有很大风险的。
跨城多机房
在不同的城市搭建多个机房,机房间通过网络进行数据复制(例如,MySQL 主备复制),但由于跨城网络时延的问题,业务上需要做一定的妥协和兼容,比如不需要数据的实时强一致性,只是保证最终一致性。
例如,微博类产品,B 用户关注了 A 用户,A 用户在北京机房发布了一条微博,B 在广州机房不需要立刻看到 A 用户发的微博,等 10 分钟看到也可以。
这种方式实现简单,但和业务有很强的相关性,微博可以这样做,支付宝的转账业务就不能这样做,因为用户余额是强一致性的。
跨国多机房
和跨城多机房类似,只是地理上分布更远,时延更大。由于时延太大和用户跨国访问实在太慢,跨国多机房一般仅用于备份和服务本国用户。
多中心
多中心必须以多机房为前提,但从设计的角度来看,多中心相比多机房是本质上的飞越,难度也高出一个等级。
简单来说,多机房的主要目标是灾备,当机房故障时,可以比较快速地将业务切换到另外一个机房,这种切换操作允许一定时间的中断(例如,10 分钟、1 个小时),而且业务也可能有损失(例如,某些未同步的数据不能马上恢复,或者要等几天才恢复,甚至永远都不能恢复了)。因此相比多机房来说,多中心的要求就高多了,要求每个中心都同时对外提供服务,且业务能够自动在多中心之间切换,故障后不需人工干预或者很少的人工干预就能自动恢复。
多中心设计的关键就在于“数据一致性”和“数据事务性”如何保证,这两个难点都和业务紧密相关,目前没有很成熟的且通用的解决方案,需要基于业务的特性进行详细的分析和设计。以淘宝为例,淘宝对外宣称自己是多中心的,但是在实际设计过程中,商品浏览的多中心方案、订单的多中心方案、支付的多中心方案都需要独立设计和实现。
正因为多中心设计的复杂性,不一定所有业务都能实现多中心,目前国内的银行、支付宝这类系统就没有完全实现多中心,不然也不会出现挖掘机一铲子下去,支付宝中断 4 小时的故障。
互联网架构模板:用户层和业务层
用户层技术
用户管理
互联网业务的一个典型特征就是通过互联网将众多分散的用户连接起来,因此用户管理是互联网业务必不可少的一部分。
稍微大一点的互联网业务,肯定会涉及多个子系统,这些子系统不可能每个都管理这么庞大的用户,由此引申出用户管理的第一个目标:单点登录(SSO),又叫统一登录。单点登录的技术实现手段较多,例如 cookie、JSONP、token 等,目前最成熟的开源单点登录方案当属 CAS,其架构如下(https://apereo.github.io/cas/4.2.x/planning/Architecture.html ):

除此之外,当业务做大成为了平台后,开放成为了促进业务进一步发展的手段,需要允许第三方应用接入,由此引申出用户管理的第二个目标:授权登录。现在最流行的授权登录就是 OAuth 2.0 协议,基本上已经成为了事实上的标准,如果要做开放平台,则最好用这个协议,私有协议漏洞多,第三方接入也麻烦。
用户管理系统面临的主要问题是用户数巨大,一般至少千万级,QQ、微信、支付宝这种巨无霸应用都是亿级用户。不过也不要被这个数据给吓倒了,用户管理虽然数据量巨大,但实现起来并不难,原因是什么呢? 因为用户数据量虽然大,但是不同用户之间没有太强的业务关联,A 用户登录和 B 用户登录基本没有关系。因此虽然数据量巨大,但用一个简单的负载均衡架构就能轻松应对。
用户管理的基本架构如下:

消息推送
消息推送根据不同的途径,分为短信、邮件、站内信、App 推送。除了 App,不同的途径基本上调用不同的 API 即可完成,技术上没有什么难度。例如,短信需要依赖运营商的短信接口,邮件需要依赖邮件服务商的邮件接口,站内信是系统提供的消息通知功能。
App 目前主要分为 iOS 和 Android 推送,iOS 系统比较规范和封闭,基本上只能使用苹果的 APNS;但 Android 就不一样了,在国外,用 GCM 和 APNS 差别不大;但是在国内,情况就复杂多了:首先是 GCM 不能用;其次是各个手机厂商都有自己的定制的 Android,消息推送实现也不完全一样。因此 Android 的消息推送就五花八门了,大部分有实力的大厂,都会自己实现一套消息推送机制,例如阿里云移动推送、腾讯信鸽推送、百度云推送;也有第三方公司提供商业推送服务,例如友盟推送、极光推送等。
通常情况下,对于中小公司,如果不涉及敏感数据,Android 系统上推荐使用第三方推送服务,因为毕竟是专业做推送服务的,消息到达率是有一定保证的。
如果涉及敏感数据,需要自己实现消息推送,这时就有一定的技术挑战了。消息推送主要包含 3 个功能:设备管理(唯一标识、注册、注销)、连接管理和消息管理,技术上面临的主要挑战有:
1)海量设备和用户管理
消息推送的设备数量众多,存储和管理这些设备是比较复杂的;同时,为了针对不同用户进行不同的业务推广,还需要收集用户的一些信息,简单来说就是将用户和设备关联起来,需要提取用户特征对用户进行分类或者打标签等。
2)连接保活
要想推送消息必须有连接通道,但是应用又不可能一直在前台运行,大部分设备为了省电省流量等原因都会限制应用后台运行,限制应用后台运行后连接通道可能就被中断了,导致消息无法及时的送达。连接保活是整个消息推送设计中细节和黑科技最多的地方,例如应用互相拉起、找手机厂商开白名单等。
3)消息管理
实际业务运营过程中,并不是每个消息都需要发送给每个用户,而是可能根据用户的特征,选择一些用户进行消息推送。由于用户特征变化很大,各种排列组合都有可能,将消息推送给哪些用户这部分的逻辑要设计得非常灵活,才能支撑花样繁多的业务需求,具体的设计方案可以采取规则引擎之类的微内核架构技术。
存储云、图片云
互联网业务场景中,用户会上传多种类型的文件数据,例如微信用户发朋友圈时上传图片,微博用户发微博时上传图片、视频,优酷用户上传视频,淘宝卖家上传商品图片等,这些文件具备几个典型特点:
1)数据量大:用户基数大,用户上传行为频繁,例如 2016 年的时候微信朋友圈每天上传图片就达到了 10 亿张(http://mi.techweb.com.cn/tmt/2016-05-25/2338330.shtml)。
2)文件体积小:大部分图片是几百 KB 到几 MB,短视频播放时间也是在几分钟内。
3)访问有时效性:大部分文件是刚上传的时候访问最多,随着时间的推移访问量越来越小。
为了满足用户的文件上传和存储需求,需要对用户提供文件存储和访问功能,这里就需要用到前面我在专栏第 40 期介绍“存储层”技术时提到的“小文件存储”技术。简单来说,存储云和图片云通常的实现都是“CDN + 小文件存储”,现在有了“云”之后,除非 BAT 级别,一般不建议自己再重复造轮子了,直接买云服务可能是最快也是最经济的方式。
既然存储云和图片云都是基于“CDN + 小文件存储”的技术,为何不统一一套系统,而将其拆分为两个系统呢?这是因为“图片”业务的复杂性导致的,普通的文件基本上提供存储和访问就够了,而图片涉及的业务会更多,包括裁剪、压缩、美化、审核、水印等处理,因此通常情况下图片云会拆分为独立的系统对用户提供服务。
业务层技术
互联网的业务千差万别,不同的业务分解下来有不同的系统,所以业务层没有办法提炼一些公共的系统或者组件。抛开业务上的差异,各个互联网业务发展最终面临的问题都是类似的:业务复杂度越来越高。也就是说,业务层面对的主要技术挑战是“复杂度”。
复杂度越来越高的一个主要原因就是系统越来越庞大,业务越来越多。幸运的是,面对业务层的技术挑战,我们有一把“屠龙宝刀”,不管什么业务难题,用上“屠龙宝刀”问题都能迎刃而解。这把“屠龙宝刀”就是“拆”,化整为零、分而治之,将整体复杂性分散到多个子业务或者子系统里面去。具体拆的方式你可以查看专栏前面可扩展架构模式部分的分层架构、微服务、微内核等。
以一个简单的电商系统为例,如下图所示:

这个模拟的电商系统经历了 3 个发展阶段:
1)第一阶段:所有功能都在 1 个系统里面。
2)第二阶段:将商品和订单拆分到 2 个子系统里面。
3)第三阶段:商品子系统和订单子系统分别拆分成了更小的 6 个子系统。
上面只是个样例,实际上随着业务的发展,子系统会越来越多,据说淘宝内部大大小小的已经有成百上千的子系统了。
随着子系统数量越来越多,如果达到几百上千,另外一个复杂度问题又会凸显出来:子系统数量太多,已经没有人能够说清楚业务的调用流程了,出了问题排查也会特别复杂。此时应该怎么处理呢,总不可能又将子系统合成大系统吧?最终答案还是“合”,正所谓“合久必分、分久必合”,但合的方式不一样,此时采取的“合”的方式是按照“高内聚、低耦合”的原则,将职责关联比较强的子系统合成一个虚拟业务域,然后通过网关对外统一呈现,类似于设计模式中的 Facade 模式。同样以电商为样例,采用虚拟业务域后,其架构如下:

互联网架构模板:平台技术
当业务规模比较小、系统复杂度不高时,运维、测试、数据分析、管理等支撑功能主要由各系统或者团队独立完成。随着业务规模越来越大,系统复杂度越来越高,子系统数量越来越多,如果继续采取各自为政的方式来实现这些支撑功能,会发现重复工作非常多。因此自然而然就会想到将这些支撑功能做成平台,避免重复造轮子,减少不规范带来的沟通和协作成本。
运维平台
运维平台核心的职责分为四大块:配置、部署、监控、应急,每个职责对应系统生命周期的一个阶段,如下图所示:

- 配置:主要负责资源的管理。例如,机器管理、IP 地址管理、虚拟机管理等。
- 部署:主要负责将系统发布到线上。例如,包管理、灰度发布管理、回滚等。
- 监控:主要负责收集系统上线运行后的相关数据并进行监控,以便及时发现问题。
- 应急:主要负责系统出故障后的处理。例如,停止程序、下线故障机器、切换 IP 等。
运维平台的核心设计要素是“四化”:标准化、平台化、自动化、可视化。
标准化
需要制定运维标准,规范配置管理、部署流程、监控指标、应急能力等,各系统按照运维标准来实现,避免不同的系统不同的处理方式。标准化是运维平台的基础,没有标准化就没有运维平台。
如果某个系统就是无法改造自己来满足运维标准,那该怎么办呢?常见的做法是不改造系统,由中间方来完成规范适配。例如,某个系统对外提供了 RESTful 接口的方式来查询当前的性能指标,而运维标准是性能数据通过日志定时上报,那么就可以写一个定时程序访问 RESTful 接口获取性能数据,然后转换为日志上报到运维平台。
平台化
传统的手工运维方式需要投入大量人力,效率低,容易出错,因此需要在运维标准化的基础上,将运维的相关操作都集成到运维平台中,通过运维平台来完成运维工作。
运维平台的好处有:
1)可以将运维标准固化到平台中,无须运维人员死记硬背运维标准。
2)运维平台提供简单方便的操作,相比之下人工操作低效且容易出错。
3)运维平台是可复用的,一套运维平台可以支撑几百上千个业务系统。
自动化
传统手工运维方式效率低下的一个主要原因就是要执行大量重复的操作,运维平台可以将这些重复操作固化下来,由系统自动完成。
例如,一次手工部署需要登录机器、上传包、解压包、备份旧系统、覆盖旧系统、启动新系统,这个过程中需要执行大量的重复或者类似的操作。有了运维平台后,平台需要提供自动化的能力,完成上述操作,部署人员只需要在最开始单击“开始部署”按钮,系统部署完成后通知部署人员即可。
类似的还有监控,有了运维平台后,运维平台可以实时收集数据并进行初步分析,当发现数据异常时自动发出告警,无须运维人员盯着数据看,或者写一大堆“grep + awk + sed”来分析日志才能发现问题。
可视化
运维平台有非常多的数据,如果全部通过人工去查询数据再来判断,则效率很低。尤其是在故障应急时,时间就是生命,处理问题都是争分夺秒,能减少 1 分钟的时间就可能挽回几十万元的损失,可视化的主要目的就是为了提升数据查看效率。
可视化的原理和汽车仪表盘类似,如果只是一连串的数字显示在屏幕上,相信大部分人一看到一连串的数字,第一感觉是眼花,而且也很难将数据与具体的情况联系起来。而有了仪表盘后,通过仪表盘的指针偏离幅度及指针指向的区域颜色,能够一目了然地看出当前的状态是低速、中速还是高速。
可视化相比简单的数据罗列,具备下面这些优点:
1)能够直观地看到数据的相关属性,例如,汽车仪表盘中的数据最小值是 0,最大是 100,单位是 MPH。
2)能够将数据的含义展示出来,例如汽车仪表盘中不同速度的颜色指示。
3)能够将关联数据整合一起展示,例如汽车仪表盘的速度和里程。
测试平台
测试平台核心的职责当然就是测试了,包括单元测试、集成测试、接口测试、性能测试等,都可以在测试平台来完成。
测试平台的核心目的是提升测试效率,从而提升产品质量,其设计关键就是自动化。传统的测试方式是测试人员手工执行测试用例,测试效率低,重复的工作多。通过测试平台提供的自动化能力,测试用例能够重复执行,无须人工参与,大大提升了测试效率。
为了达到“自动化”的目标,测试平台的基本架构如下图所示:

用例管理
测试自动化的主要手段就是通过脚本或者代码来进行测试,例如单元测试用例是代码、接口测试用例可以用 Python 来写、可靠性测试用例可以用 Shell 来写。为了能够重复执行这些测试用例,测试平台需要将用例管理起来,管理的维度包括业务、系统、测试类型、用例代码。例如,网购业务的订单系统的接口测试用例。
资源管理
测试用例要放到具体的运行环境中才能真正执行,运行环境包括硬件(服务器、手机、平板电脑等)、软件(操作系统、数据库、Java 虚拟机等)、业务系统(被测试的系统)。
除了性能测试,一般的自动化测试对性能要求不高,所以为了提升资源利用率,大部分的测试平台都会使用虚拟技术来充分利用硬件资源,如虚拟机、Docker 等技术。
任务管理
任务管理的主要职责是将测试用例分配到具体的资源上执行,跟踪任务的执行情况。任务管理是测试平台设计的核心,它将测试平台的各个部分串联起来从而完成自动化测试。
数据管理
测试任务执行完成后,需要记录各种相关的数据(例如,执行时间、执行结果、用例执行期间的 CPU、内存占用情况等),这些数据具备下面这些作用:
1)展现当前用例的执行情况。
2)作为历史数据,方便后续的测试与历史数据进行对比,从而发现明显的变化趋势。例如,某个版本后单元测试覆盖率从 90% 下降到 70%。
3)作为大数据的一部分,可以基于测试的任务数据进行一些数据挖掘。例如,某个业务一年执行了 10000 个用例测试,另外一个业务只执行了 1000 个用例测试,两个业务规模和复杂度差不多,为何差异这么大?
数据平台
数据平台的核心职责主要包括三部分:数据管理、数据分析和数据应用。每一部分又包含更多的细分领域,详细的数据平台架构如下图所示:

数据管理
数据管理包含数据采集、数据存储、数据访问和数据安全四个核心职责,是数据平台的基础功能。
1)数据采集:从业务系统搜集各类数据。例如,日志、用户行为、业务数据等,将这些数据传送到数据平台。
2)数据存储:将从业务系统采集的数据存储到数据平台,用于后续数据分析。
3)数据访问:负责对外提供各种协议用于读写数据。例如,SQL、Hive、Key-Value 等读写协议。
4)数据安全:通常情况下数据平台都是多个业务共享的,部分业务敏感数据需要加以保护,防止被其他业务读取甚至修改,因此需要设计数据安全策略来保护数据。
数据分析
数据分析包括数据统计、数据挖掘、机器学习、深度学习等几个细分领域。
1)数据统计:根据原始数据统计出相关的总览数据。例如,PV、UV、交易额等。
2)数据挖掘:数据挖掘这个概念本身含义可以很广,为了与机器学习和深度学习区分开,这里的数据挖掘主要是指传统的数据挖掘方式。例如,有经验的数据分析人员基于数据仓库构建一系列规则来对数据进行分析从而发现一些隐含的规律、现象、问题等,经典的数据挖掘案例就是沃尔玛的啤酒与尿布的关联关系的发现。
3)机器学习、深度学习:机器学习和深度学习属于数据挖掘的一种具体实现方式,由于其实现方式与传统的数据挖掘方式差异较大,因此数据平台在实现机器学习和深度学习时,需要针对机器学习和深度学习独立进行设计。
数据应用
数据应用很广泛,既包括在线业务,也包括离线业务。例如,推荐、广告等属于在线应用,报表、欺诈检测、异常检测等属于离线应用。
数据应用能够发挥价值的前提是需要有“大数据”,只有当数据的规模达到一定程度,基于数据的分析、挖掘才能发现有价值的规律、现象、问题等。如果数据没有达到一定规模,通常情况下做好数据统计就足够了,尤其是很多初创企业,无须一开始就参考 BAT 来构建自己的数据平台。
管理平台
管理平台的核心职责就是权限管理,无论是业务系统(例如,淘宝网)、中间件系统(例如,消息队列 Kafka),还是平台系统(例如,运维平台),都需要进行管理。如果每个系统都自己来实现权限管理,效率太低,重复工作很多,因此需要统一的管理平台来管理所有的系统的权限。
权限管理主要分为两部分:身份认证、权限控制,其基本架构如下图所示。

身份认证
确定当前的操作人员身份,防止非法人员进入系统。例如,不允许匿名用户进入系统。为了避免每个系统都自己来管理用户,通常情况下都会使用企业账号来做统一认证和登录。
权限控制
根据操作人员的身份确定操作权限,防止未经授权的人员进行操作。例如,不允许研发人员进入财务系统查看别人的工资。
架构重构01:有的放矢
相比全新的架构设计来说,架构重构对架构师的要求更高,主要体现在:
1)业务已经上线,不能停下来架构重构时,业务已经上线运行了,重构既需要尽量保证业务继续往前发展,又要完成架构调整,这就好比“给飞行中的波音 747 换引擎”;而如果是新设计架构,业务还没有上线,则即使做砸了对业务也不会有太大影响。
2)关联方众多,牵一发动全身架构重构涉及的业务关联方很多,不同关联方的资源投入程度、业务发展速度、对架构痛点的敏感度等有很大差异,如何尽量减少对关联方的影响,或者协调关联方统一行动,是一项很大的挑战;而如果是新设计架构,则在新架构上线前,对关联方没有影响。
3)旧架构的约束
架构重构需要在旧的架构基础上进行,这是一个很强的约束,会限制架构师的技术选择范围;而如果是新设计架构,则架构师的技术选择余地大得多。
即使是决定推倒到重来,完全抛弃旧的架构而去设计新的架构,新架构也会受到旧架构的约束和影响,因为业务在旧架构上产生的数据是不能推倒重来的,新架构必须考虑如何将旧架构产生的数据转换过来。
因此,架构重构对架构师的综合能力要求非常高,业务上要求架构师能够说服产品经理暂缓甚至暂停业务来进行架构重构;团队上需要架构师能够与其他团队达成一致的架构重构计划和步骤;技术上需要架构师给出让技术团队认可的架构重构方案。
总之,架构重构需要架构师既要说得动老板,也要镇得住同事;既要技术攻关,又要协调资源;既要保证业务正常发展,又要在指定时间内完成目标……总之就是十八般武艺要样样精通。
说了那么多架构重构的难度,千万不要被困难所吓倒,架构师正是需要在原来一团乱麻中找到线索,然后重新穿针引线,帮助业务进一步腾飞发展。接下来架构重构第一招:有的放矢。
通常情况下,当系统架构不满足业务的发展时,其表现形式是系统不断出现各种问题,轻微一点的如系统响应慢、数据错误、某些用户访问失败等,严重的可能是宕机、数据库瘫痪、数据丢失等,或者系统的开发效率很低。开始的时候,技术团队可能只针对具体的问题去解决,解决一个算一个,但如果持续时间较长,例如持续了半年甚至一年情况都不见好转,此时可能有人想到了系统的架构是否存在问题,讨论是否是因为架构原因导致了各种问题。一旦确定需要进行架构重构,就会由架构师牵头来进行架构重构的分析。
当架构师真正开始进行架构重构分析时,就会发现自己好像进了一个迷雾森林,到处都是问题,每个问题都需要解决,不知道出路在哪里,感觉如果要解决所有这些问题,架构重构其实也无能为力。有的架构师一上来搜集了系统当前存在的问题,然后汇总成一个 100 行的 Excel 表格,看到这样一个表格就懵了:这么多问题,要到猴年马月才能全部解决完啊?
期望通过架构重构来解决所有问题当然是不现实的,所以架构师的首要任务是从一大堆纷繁复杂的问题中识别出真正要通过架构重构来解决的问题,集中力量快速解决,而不是想着通过架构重构来解决所有的问题。否则就会陷入人少事多头绪乱的处境,团队累死累活弄个大半年,最后发现好像什么都做了,但每个问题都依然存在。尤其是对于刚接手一个新系统的架构师或者技术主管来说,一定要控制住“新官上任三把火”的冲动,避免摊大饼式或者运动式的重构和优化。
下面看几个重构案例
后台系统:不合理的耦合
M 系统是一个后台管理系统,负责管理所有游戏相关的数据,重构的主要原因是因为系统耦合了 P 业务独有的数据和所有业务公用的数据,导致可扩展性比较差。其大概架构如下图所示:

举一个简单的例子:数据库中的某张表,一部分字段是所有业务公用的“游戏数据”,一部分字段是 P 业务系统“独有的数据”,开发时如果要改这张表,代码和逻辑都很复杂,改起来效率很低。
针对 M 系统存在的问题,重构目标就是将游戏数据和业务数据拆分,解开两者的耦合,使得两个系统都能够独立快速发展。重构的方案如下图所示:

重构后的效果非常明显,重构后的 M 系统和 P 业务后台系统每月上线版本数是重构前的 4 倍!
游戏接入系统:全局单点的可用性
S 系统是游戏接入的核心系统,一旦 S 系统故障,大量游戏玩家就不能登录游戏。而 S 系统并不具备多中心的能力,一旦主机房宕机,整个 S 系统业务就不可用了。其大概架构如下图所示,可以看出数据库主库是全局单点,一旦数据库主库不可用,两个集群的写业务都不可用了。

针对 S 系统存在的问题,重构目标就是实现双中心,使得任意一个机房都能够提供完整的服务,在某个机房故障时,另外一个机房能够全部接管所有业务。重构方案如下图所示:

重构后系统的可用性从 3 个 9 提升到 4 个 9,重构前最夸张的一个月有 4 次较大的线上故障,重构后虽然也经历了机房交换机宕机、运营商线路故障、机柜断电等问题,但对业务都没有什么大的影响。
大系统带来的开发效率
X 系统是创新业务的主系统,之前在业务快速尝试和快速发展期间,怎么方便怎么操作,怎么快速怎么做,系统设计并未投入太多精力和时间,很多东西都“塞”到同一个系统中,导致到了现在已经改不动了。做一个新功能或者新业务,需要花费大量的时间来讨论和梳理各种业务逻辑,一不小心就踩个大坑。X 系统的架构如下图所示:

X 系统的问题看起来和 M 系统比较类似,都是可扩展性存在问题,但其实根本原因不一样:M 系统是因为耦合了不同业务的数据导致系统可扩展性不足,而 X 系统是因为将业务相关的所有功能都放在同一个系统中,导致系统可扩展性不足;同时,所有功能都在一个系统中,也可能导致一个功能出问题,整站不可用。比如说某个功能把数据库拖慢了,整站所有业务跟着都慢了。针对 X 系统存在的问题,重构目标是将各个功能拆分到不同的子系统中,降低单个系统的复杂度。重构后的架构如下图所示(仅仅是示例,实际架构远比下图复杂):

重构后各个系统之间通过接口交互,虽然看似增加了接口的工作量,但整体来说,各系统的发展和开发速度比原来快了很多,系统也相对更加简单,也不会出现某个子系统有问题,所有业务都有问题。
这三个系统重构的方案,现在回过头来看,感觉是理所当然的,但实际上当时做分析和决策时,远远没有这么简单。以 M 系统为例,当时我们接手后遇到的问题有很多,例如:
1)数据经常出错。M 系统是单机,单机宕机后所有后台操作就不能进行了。
2)性能比较差,有的操作耗时好久。
3)界面比较丑,操作不人性化。
4)历史上经过几手转接,代码比较混乱。
5)业务数据和游戏数据耦合,开发效率很低。
从这么多问题中识别出重构的目标,并不是一目了然的;而如果想一下全部解决所有这些问题,人力和时间又不够!所以架构师需要透过问题表象看到问题本质,找出真正需要通过架构重构解决的核心问题,从而做到有的放矢,既不会耗费大量的人力和时间投入,又能够解决核心问题。这对架构师的分析和判断能力要求非常高,既不能看到问题就想到要架构重构,也不能只是针对问题进行系统优化,判断到底是采取架构重构还是采取系统优化,可能不同的架构师和团队都有不同的看法。这里分享一个简单的做法:假设我们现在需要从 0 开始设计当前系统,新架构和老架构是否类似?如果差异不大,说明采取系统优化即可;如果差异很大,那可能就要进行系统重构了。
那原来发现的那些非架构重构问题怎么办呢?当然不能放任不管。以 M 系统为例,我们在重构完成后,又启动了多个优化的项目去优化这些问题,但此时的优化主要由团队内部完成即可,和其他团队没有太多关联,优化的速度是很快的。如果没有重构就进行优化,则每次优化都要拉一大堆关联业务的团队来讨论方案,效率非常低下!
架构重构02:合纵连横
合纵
架构重构是大动作,持续时间比较长,而且会占用一定的研发资源,包括开发和测试,因此不可避免地会影响业务功能的开发。因此,要想真正推动一个架构重构项目启动,需要花费大量的精力进行游说和沟通。注意这里不是指办公室政治,而是指要和利益相关方沟通好,让大家对于重构能够达成一致共识,避免重构过程中不必要的反复和争执。
一般的技术人员谈到架构重构时,就会搬出一大堆技术术语:可扩展性、可用性、性能、耦合、代码很乱……但从过往的实际经验来看,如果和非技术人员这样沟通,效果如同鸡同鸭讲,没有技术背景的人员很难理解,甚至有可能担心是在忽悠人。
在沟通协调时,将技术语言转换为通俗语言,以事实说话,以数据说话,是沟通的关键!
连横
除了上面讨论的和上下游沟通协调,有的重构还需要和其他相关或者配合的系统的沟通协调。由于大家都是做技术的,有比较多的共同语言,所以这部分的沟通协调其实相对来说要容易一些,但也不是说想推动就能推动的,主要的阻力来自“这对我有什么好处”和“这部分我这边现在不急”。
对于“这对我有什么好处”问题,有的人会简单理解为这是自私的表现,认为对方不顾大局,于是沟通的时候将问题人为拔高。例如“你应该站在部门的角度来考虑这个问题”“这对公司整体利益有帮助”等。这种沟通效果其实很差,首先是这种拔高一般都比较虚,无法明确,不同的人理解也不一样,无法达成共识;其次是如果对公司和部门有利,但对某个小组没用甚至不利,那么可能是因为目前的方案不够好,还可以考虑另外的方案。
那如何才能有效地推动呢?有效的策略是“换位思考、合作双赢、关注长期”。简单来说就是站在对方的角度思考,重构对他有什么好处,能够帮他解决什么问题,带来什么收益。
以上一章的 M 系统为例,当时有另外一个 C 系统和 M 系统通过数据库直连共用数据库,我们的重构方案是要去掉两个系统同时在底层操作数据库,改为 C 系统通过调用 M 系统接口来写入数据库。这个方案对 C 系统来说,很明显的一点就是 C 系统短期的改动比较大,要将十几个功能都从直接读写数据库改为跨系统接口调用。刚开始 C 系统也是觉得重构对他们没有什么作用,后来我们经过分析和沟通,了解到 C 系统其实也深受目前这种架构之苦,主要体现在“数据经常出错要排查”(因为 C 系统和 M 系统都在写同一个数据库,逻辑很难保证完全一致)、“要跟着 M 系统同步开发”(因为 M 系统增加表或者字段,C 系统要从数据库自己读取出来,还要理解逻辑)、“C 系统要连两个数据库,出问题不好查”(因为 C 系统自己还有数据库)……这些问题其实在 M 系统重构后都可以解决,虽然短期内 C 系统有一定的开发工作量,但从中长期来看,C 系统肯定可以省很多事情。例如,数据问题排查主要是 M 系统的事情了,通过 M 系统的接口获取数据,无须关注数据相关的业务逻辑等。通过这种方式沟通协调,C 系统很乐意跟我们一起做重构,而且事实也证明重构后对 C 系统和 M 系统都有很大好处。
当然如果真的出现了对公司或者部门有利,对某个小组不利的情况,那可能需要协调更高层级的管理者才能够推动,平级推动是比较难的。
对于“这部分我们现在不急”问题,有的人可能会认为这是在找借口,也不排除这种可能性。但就算真的是找借口,那也是因为大家没有达成一致意见,可能对方不好意思直接拒绝。所以这种情况就可以参考上面“这对我有什么好处”问题的处理方法来处理。
如果对方真的是因为有其他更重要的业务,此时勉为其难也不好,还是那句话:换位思考!因为大部分重构的系统并不是到了火烧眉毛非常紧急的时候才开始启动的,而是有一定前瞻性的规划,如果对方真的有其他更加重要的事情,采取等待的策略也未尝不可,但要明确正式启动的时间。例如,3 个月后开始、6 月份开始,千万不能说“以后”“等不忙的时候”这种无法明确的时间点。
除了计划上灵活一点,方案上也可以灵活一点:我们可以先不做这个系统相关的重构,先把其他需要重构的做完。因为大部分需要重构的系统,需要做的事情很多,分阶段处理,在风险规避、计划安排等方面更加灵活可控。
架构重构03:运筹帷幄
通常情况下,需要架构重构的系统,基本上都是因为各种历史原因和历史问题没有及时处理,遗留下来逐渐积累,然后到了一个临界点,各种问题开始互相作用,集中爆发!到了真正要开始重构的时候,架构师识别出系统关键的复杂度问题后,如果只针对这个复杂度问题进行架构重构,可能会发现还是无法落地,因为很多条件不具备或者有的问题没解决的情况下就是不能做架构重构。因此,架构师在识别系统关键的复杂度问题后,还需要识别为了解决这个问题,需要做哪些准备事项,或者还要先解决哪些问题。这就需要架构重构第三招:运筹帷幄。
经过分析和思考,可能从最初的 100 个问题列表,挑选出其中 50 个是需要在架构重构中解决的,其中一些是基础能力建设或者准备工作,而另外一些就是架构重构的核心工作。有了这样一个表格后,那我们应该怎么去把这 50 个问题最终解决呢?
最简单的做法是每次从中挑一个解决,最终总会把所有的问题都解决。这种做法操作起来比较简单,但效果会很差,为什么呢?
第一个原因是没有区分问题的优先级,所有问题都一视同仁,没有集中有限资源去解决最重要或者最关键的问题,导致最后做了大半年,回头一看好像做了很多事情,但没取得什么阶段性的成果。
第二个原因是没有将问题分类,导致相似问题没有统筹考虑,方案可能出现反复,效率不高。
第三个原因是会迫于业务版本的压力,专门挑容易做的实施,到了稍微难一点的问题的时候,就因为复杂度和投入等原因被搁置,达不到重构的真正目的。
以 X 系统为例,之前其实也整理了系统目前存在的问题,大的项包括可用性、性能、安全、用户体验等,每个大项又包括十几二十个子项。但是实施时基本上就是挑软柿子捏,觉得哪个好落地、占用资源不太多,就挑来做,结果做了半年,好像做了很多功能,但整体却没什么进展。
成立了一个“X 项目”后,在原来整理的问题基础上,识别出架构的核心复杂度体现在庞大的系统集成了太多功能,可扩展性不足;但目前系统的可用性也不高,经常出线上问题,耗费大量的人力去处理。因此又识别出如果要做架构重构,就需要系统处于一个比较稳定的状态,不要经常出线上问题。而目前系统的可用性性不高,有的是因为硬件资源不够用了,或者某些系统组件使用不合理,有的是因为架构上存在问题。
基于这些分析,制定了总体的策略,如下图所示:

可以看到,真正的架构重构在第三阶段,第一阶段和第二阶段都是为了第三阶段做准备而已,但如果没有第一阶段和第二阶段的铺垫,直接开始第三阶段的架构重构工作,架构重构方案需要糅合第一阶段和第二阶段的一些事项(例如,业务降级、接入服务中心等),会导致架构重构方案不聚焦,而且异常复杂。
为什么最终采用这样一个策略呢?主要还是为了集中有限的资源,某个阶段集中解决某一类问题。这样做首先是效率高,因为阶段目标比较明确,做决策和方案的时候无须进行太多选择;其次是每个阶段都能看到明显的成果,给团队很大的信心。比如说第一阶段的“救火”,做完之后,系统很少有因为机器过载、缓存响应慢、虚拟机挂死等问题导致的故障了;完成第二阶段的事项后,因为组件、外部系统故障导致系统故障的问题也很少了。完成前两个阶段后,我们就可以安心地做第三阶段的“服务化”工作了。
S 系统的重构做法也是类似,但 S 系统当时面临的主要问题就是可用性不高,并没有系统耦合的问题,所以我们当时的策略是“先救火、后优化、再重构”。“救火”阶段做了扩容(防止资源不足导致系统被压死)和 Nginx 一键切换功能(故障时快速切换);优化阶段将一些明显的可用性问题解决(包括性能问题等);重构阶段将原来的单点数据库改为多中心。
总结一下重构的做法,其实就是“分段实施”,将要解决的问题根据优先级、重要性、实施难度等划分为不同的阶段,每个阶段聚焦于一个整体的目标,集中精力和资源解决一类问题。这样做有几个好处:
1)每个阶段都有明确目标,做完之后效果明显,团队信心足,后续推进更加容易。
2)每个阶段的工作量不会太大,可以和业务并行。
3)每个阶段的改动不会太大,降低了总体风险。
具体如何制定“分段实施”的策略呢?
1)优先级排序
将明显且又比较紧急的事项优先落地,解决目前遇到的主要问题。例如,扩容在 S 系统和 X 系统中都是最优先实施的,因为如果不扩容,系统隔三差五一会出现响应超时报警,一会来个过载报警,一会来个大面积不可用……这些问题耗费大量的人力和精力,也就没法做其他事情了。
2)问题分类将问题按照性质分类,每个阶段集中解决一类问题。例如,X 系统的第二阶段,我们将多个底层系统切换到公司统一的公共组件,提升整体可用性。
3)先易后难
这点与很多人的直觉不太一样,有的人认为应该先攻克最难的问题,所谓“擒贼先擒王”,解决最难的问题后其他问题就不在话下。这样看起来很美好,但实际上不可行。
首先,一开始就做最难的部分,会发现想要解决这个最难的问题,要先解决其他容易的问题。
其次,最难的问题解决起来耗时都比较长,占用资源比较多,如果一开始做最难的,可能做了一两个月还没有什么进展和成果,会影响相关人员对项目的评价和看法,也可能影响团队士气。
第三,刚开始的分析并不一定全面,所以一开始对最难的或者最关键的事项的判断可能会出错。
采取“先易后难”的策略,能够很大程度上避免“先难后易”策略的问题。
首先,随着项目的推进,一些相对简单的问题逐渐解决,会发现原来看起来很难的问题已经不那么难了,甚至有的问题可能都消失了。
其次,先易后难能够比较快地看到成果,虽然成果可能不大,但至少能看到一些成效了,对后续的项目推进和提升团队士气有很大好处。
第三,随着项目的进行,原来遗漏的一些点,或者分析和判断错误的点,会逐渐显示出来,及时根据实际情况进行调整,能够有效地保证整个重构的效果。
4)循序渐进
按照前 3 个步骤划分了架构重构的实施阶段后,就需要评估每个阶段所需要耗费的时间,很可能会出现有的阶段耗时可能只要 1 个月,而有的却需要 6 个月,虽然这可能确实是客观事实,但通常情况下,按照固定的步骤和节奏,更有利于项目推进。我的经验是每个阶段最少 1 个月,最长不要超过 3 个月,如果评估超过 3 个月的,那就再拆分为更多阶段。就像 X 项目,我们先划分了阶段,每个阶段又分了任务子集,当任务子集比较小的时候,多个任务子集可以并行;当任务子集比较大的时候,就当成一个独立的里程碑推进。
开源项目:选择、使用及二次开发
软件开发领域有一个流行的原则:DRY,Don’t repeat yourself。翻译过来更通俗易懂:不要重复造轮子。开源项目的主要目的是共享,其实就是为了让大家不要重复造轮子,尤其是在互联网这样一个快速发展的领域,速度就是生命,引入开源项目可以节省大量的人力和时间,大大加快业务的发展速度,何乐而不为呢?
然而现实往往没有那么美好,开源项目虽然节省了大量的人力和时间,但带来的问题也不少,相信绝大部分技术人员都踩过开源软件的坑,小的影响可能是宕机半小时,大的问题可能是丢失几十万条数据,甚至灾难性的事故是全部数据都丢失。
除此以外,虽然 DRY 原则摆在那里,但实际上开源项目反而是最不遵守 DRY 原则的,重复的轮子好多,你有 MySQL,我有 PostgreSQL;你有 MongoDB,我有 Cassandra;你有 Memcached,我有 Redis;你有 Gson,我有 Jackson;你有 Angular,我有 React……总之放眼望去,其实相似的轮子很多!相似轮子太多,如何选择就成了让人头疼的问题了。
完全不用开源项目几乎是不可能的,架构师需要更加聪明地选择和使用开源项目。形象点说:不要重复发明轮子,但要找到合适的轮子!
选:如何选择一个开源项目
聚焦是否满足业务
架构师在选择开源项目时,一个头疼的问题就是相似的开源项目较多,而且后面的总是要宣称比前面的更加优秀。有的架构师在选择时有点无所适从,总是会担心选择了 A 项目而错过了 B 项目。这个问题的解决方式是聚焦于是否满足业务,而不需要过于关注开源项目是否优秀。
Tokyo Tyrant 的教训
在开发一个社交类业务时,使用了 TT(Tokyo Tyrant)开源项目,觉得既能够做缓存取代 Memcached,又有持久化存储功能,还可以取代 MySQL,觉得很强大,于是就在业务里面大量使用了。但后来的使用过程让人很郁闷,主要表现为:不能完全取代 MySQL,因此有两份存储,设计时每次都要讨论和决策究竟什么数据放 MySQL,什么数据放 TT。功能上看起来很高大上,但相应的 bug 也不少,而且有的 bug 是致命的。例如所有数据不可读,后来是自己研究源码写了一个工具才恢复了部分数据。功能确实强大,但需要花费较长时间熟悉各种细节,不熟悉随便用很容易踩坑。后来反思和总结,其实当时的业务 Memcached + MySQL 完全能够满足,而且大家都熟悉,其实完全不需要引入 TT。
简单来说:如果业务要求 1000 TPS,那么一个 20000 TPS 和 50000 TPS 的项目是没有区别的。有的架构师可能会担心 TPS 不断上涨怎么办?其实不用过于担心,架构是可以不断演进的,等到真的需要这么高的时候再来架构重构,这里的设计决策遵循架构设计原则中的“合适原则”和”演化原则”。
聚焦是否成熟
很多新的开源项目往往都会声称自己比以前的项目更加优秀:性能更高、功能更强、引入更多新概念……看起来都很诱人,但实际上都有意无意地隐藏了一个负面的问题:更加不成熟!不管多优秀的程序员写出来的项目都会有 bug,千万不要以为作者历害就没有 bug,Windows、Linux、MySQL 的开发者都是顶级的开发者,系统一样有很多 bug。
不成熟的开源项目应用到生产环境,风险极大:轻则宕机,重则宕机后重启都恢复不了,更严重的是数据丢失都找不回来。还是以上面提到的 TT 为例:真的遇到异常断电后,文件被损坏,重启也恢复不了的故障。还好当时每天做了备份,于是只能用 1 天前的数据进行恢复,但当天的数据全部丢失了。花费了大量的时间和人力去看源码,自己写工具恢复了部分数据,好在这些数据不是金融相关的数据,丢失一部分问题也不大,否则就有大麻烦了。所以在选择开源项目时,尽量选择成熟的开源项目,降低风险。
可以从这几个方面考察开源项目是否成熟:
1)版本号:除非特殊情况,否则不要选 0.X 版本的,至少选 1.X 版本的,版本号越高越好。
2)使用的公司数量:一般开源项目都会把采用了自己项目的公司列在主页上,公司越大越好,数量越多越好。
3)社区活跃度:看看社区是否活跃,发帖数、回复数、问题处理速度等。
聚焦运维能力
大部分架构师在选择开源项目时,基本上都是聚焦于技术指标,例如性能、可用性、功能这些评估点,而几乎不会去关注运维方面的能力。但如果要将项目应用到线上生产环境,则运维能力是必不可少的一环,否则一旦出问题,运维、研发、测试都只能干瞪眼。
可以从这几个方面去考察运维能力:
1)开源项目日志是否齐全:有的开源项目日志只有寥寥启动停止几行,出了问题根本无法排查。
2)开源项目是否有命令行、管理控制台等维护工具,能够看到系统运行时的情况。
3)开源项目是否有故障检测和恢复的能力,例如告警、切换等。
如果是开源库,例如 Netty 这种网络库,本身是不具备运维能力的,那么就需要在使用库的时候将一些关键信息通过日志记录下来,例如在 Netty 的 Handler 里面打印一些关键日志。
用:如何使用开源项目
深入研究,仔细测试
很多人用开源项目,其实是完完全全的“拿来主义”,看了几个 Demo,把程序跑起来就开始部署到线上应用了。这就好像看了一下开车指南,知道了方向盘是转向、油门是加速、刹车是减速,然后就开车上路了,其实是非常危险的。
Elasticsearch 的案例:有团队使用了 Elasticsearch,基本上是拿来就用,倒排索引是什么都不太清楚,配置都是用默认值,跑起来就上线了,结果就遇到节点 ping 时间太长,剔除异常节点太慢,导致整站访问挂掉。
MySQL 的案例:很多团队最初使用 MySQL 时,也没有怎么研究过,经常有业务部门抱怨 MySQL 太慢了。但经过定位,发现最关键的几个参数(例如,innodb_buffer_pool_size、sync_binlog、innodb_log_file_size 等)都没有配置或者配置错误,性能当然会慢。
可以从这几方面进行研究和测试:
1)通读开源项目的设计文档或者白皮书,了解其设计原理。
2)核对每个配置项的作用和影响,识别出关键配置项。
3)进行多种场景的性能测试。
4)进行压力测试,连续跑几天,观察 CPU、内存、磁盘 I/O 等指标波动。
5)进行故障测试:kill、断电、拔网线、重启 100 次以上、切换等。
小心应用,灰度发布
假如做了上面的“深入研究、仔细测试”,发现没什么问题,是否就可以放心大胆地应用到线上了呢?别高兴太早,即使研究再深入,测试再仔细,还是要小心为妙,因为再怎么深入地研究,再怎么仔细地测试,都只能降低风险,但不可能完全覆盖所有线上场景。
做好应急,以防万一
即使前面的工作做得非常完善和充分,也不能认为万事大吉,尤其是刚开始使用一个开源项目,运气不好可能遇到一个之前全世界的使用者从来没遇到的 bug,导致业务都无法恢复,尤其是存储方面,一旦出现问题无法恢复,可能就是致命的打击。
MongoDB 丢失数据
某个业务使用了 MongoDB,结果宕机后部分数据丢失,无法恢复,也没有其他备份,人工恢复都没办法,只能接一个用户投诉处理一个,导致 DBA 和运维从此以后都反对用 MongoDB,即使是尝试性的。虽然因为一次故障就完全反对尝试是有点反应过度了,但确实故障也提了一个醒:对于重要的业务或者数据,使用开源项目时,最好有另外一个比较成熟的方案做备份,尤其是数据存储。例如,如果要用 MongoDB 或者 Redis,可以用 MySQL 做备份存储。这样做虽然复杂度和成本高一些,但关键时刻能够救命!
改:基于开源项目二次开发
保持纯洁,加以包装
当发现开源项目有的地方不满足需求时,自然会有一种去改改的冲动,但是怎么改是个大学问。一种方式是投入几个人从内到外全部改一遍,将其改造成完全符合业务需求。但这样做有几个比较严重的问题:
1)投入太大,一般来说,Redis 这种级别的开源项目,真要自己改,至少要投入 2 个人,搞 1 个月以上。
2)失去了跟随原项目演进的能力:改的太多,即使原有开源项目继续演进,也无法合并了,因为差异太大。
所以建议是不要改动原系统,而是要开发辅助系统:监控、报警、负载均衡、管理等。以 Redis 为例,如果想增加集群功能,则不要去改动 Redis 本身的实现,而是增加一个 proxy 层来实现。Twitter 的 Twemproxy 就是这样做的,而 Redis 到了 3.0 后本身提供了集群功能,原有的方案简单切换到 Redis 3.0 即可(详细可参考这里)。
如果实在想改到原有系统,建议是直接给开源项目提需求或者 bug,但弊端就是响应比较缓慢,这个就要看业务紧急程度了,如果实在太急那就只能自己改了;如果不是太急,建议做好备份或者应急手段即可。
发明你要的轮子
这一点估计让你大跌眼镜,怎么讲了半天,最后又回到了“重复发明你要的轮子”呢?其实选与不选开源项目,核心还是一个成本和收益的问题,并不是说选择开源项目就一定是最优的项目,最主要的问题是:没有完全适合你的轮子!
软件领域和硬件领域最大的不同就是软件领域没有绝对的工业标准,大家想怎么玩就怎么玩。不像硬件领域,造一个尺寸与众不同的轮子,其他车都用不上,轮子工艺再高,质量再好也是白费;软件领域可以造很多相似的轮子,基本上能到处用。例如,把缓存从 Memcached 换成 Redis,不会有太大的问题。
除此以外,开源项目为了能够大规模应用,考虑的是通用的处理方案,而不同的业务其实差异较大,通用方案并不一定完美适合具体的某个业务。比如说 Memcached,通过一致性 Hash 提供集群功能,但是我们的一些业务,缓存如果有一台宕机,整个业务可能就被拖慢了,这就要求我们提供缓存备份的功能。但 Memcached 又没有,而 Redis 当时又没有集群功能,于是投入 2~4 个人花了大约 2 个月时间基于 LevelDB 的原理,自己做了一套缓存框架支持存储、备份、集群的功能,后来又在这个框架的基础上增加了跨机房同步的功能,很大程度上提升了业务的可用性水平。如果完全采用开源项目,等开源项目来实现,是不可能这么快速的,甚至开源项目完全就不支持需求。
如果有钱有人有时间,投入人力去重复发明完美符合自己业务特点的轮子也是很好的选择!毕竟,很多财大气粗的公司(BAT 等)都是这样做的,否则也就没有那么多好用的开源项目了。
App 架构的演进
首先,复习一下之前所讲述的架构设计理念,可以提炼为下面几个关键点:
1)架构是系统的顶层结构。
2)架构设计的主要目的是为了解决软件系统复杂度带来的问题。
3)架构设计需要遵循三个主要原则:合适原则、简单原则、演化原则。
4)架构设计首先要掌握业界已经成熟的各种架构模式,然后再进行优化、调整、创新。
Web App
最早的 App 有很多采用这种架构,大多数尝试性的业务,一开始也是这样的架构。Web App 架构又叫包壳架构,简单来说就是在 Web 的业务上包装一个 App 的壳,业务逻辑完全还是 Web 实现,App 壳完成安装的功能,让用户看起来像是在使用 App,实际上和用浏览器访问 PC 网站没有太大差别。
为何早期的 App 或者尝试新的业务采用这种架构比较多呢?简单来说,就是当时业务面临的复杂度决定的。以早期的 App 为例,大约在 2010 年前后,移动互联网虽然发展很迅速,但受限于用户的设备、移动网络的速度等约束,PC 互联网还是主流,移动互联网还是一个新鲜事物,未来的发展前景和发展趋势,其实当年大家也不一定能完全看得清楚。例如淘宝也是在 2013 年才开始决定“All in 无线”的,在这样的业务背景下,当时的业务重心还是在 PC 互联网上,移动互联网更多是尝试性的。既然是尝试,那就要求快速和低成本,虽然当时的 Android 和 iOS 已经都有了开发 App 的功能,但原生的开发成本太高,因此自然而然,Web App 这种包壳架构就被大家作为首选尝试架构了,其主要解决“快速开发”和“低成本”两个复杂度问题,架构设计遵循“合适原则”和“简单原则”。
原生 App
Web App 虽然解决了“快速开发”和“低成本”两个复杂度问题,但随着业务的发展,Web App 的劣势逐渐成为了主要的复杂度问题,主要体现在:
1)移动设备的发展速度远远超过 Web 技术的发展速度,因此 Web App 的体验相比原生 App 的体验,差距越来越明显。
2)移动互联网飞速发展,趋势越来越明显,App 承载的业务逻辑也越来越复杂,进一步加剧了 Web App 的体验问题。
3)移动设备在用户体验方面有很多优化和改进,而 Web App 无法利用这些技术优势,只有原生 App 才能够利用这些技术优势。
因此,随着业务发展和技术演进,移动开发的复杂度从“快速开发”和“低成本”转向了“用户体验”,而要保证用户体验,采用原生 App 的架构是最合适的,这里的架构设计遵循“演化原则”。
原生 App 解决了用户体验问题,大约在 2013 年前后开始快速发展,那个时候的 Android 工程师和 iOS 工程师就像现在的人工智能工程师一样非常抢手,很多同学也是那时候从后端转行到 App 开发的。
Hybrid App
原生 App 很好的解决了用户体验问题,但业务和技术也在发展,移动互联网此时已经成为明确的大趋势,团队需要考虑的不是要不要转移动互联网的问题,而是要考虑如何在移动互联网更具竞争力的问题,因此各种基于移动互联网特点的功能和体验方式不断被创造出来,大家拼的竞争方式就是看谁更快抓住用户需求和痛点。因此,移动开发的复杂度又回到了“快速开发”,这时就发现了原生 App 开发的痛点:由于 Android、iOS、Windows Phone(当年确实是这三个主流平台)的原生开发完全不能兼容,同样的功能需要三个平台重复开发,每个平台还有一些差异,因此自然快不起来。
为了解决“快速开发”的复杂度问题,大家自然又想到了 Web 的方式,但 Web 的体验还是远远不如原生,怎么解决这个问题呢?其实没有办法完美解决,但可以根据不同的业务要求选取不同的方案,例如对体验要求高的业务采用原生 App 实现,对体验要求不高的可以采用 Web 的方式实现,这就是 Hybrid App 架构的核心设计思想,主要遵循架构设计的“合适原则”。
组件化 & 容器化
Hybrid App 能够较好的平衡“用户体验”和“快速开发”两个复杂度问题(注意是“平衡”,不是“同时解决”),但对于一些超级 App 来说,随着业务规模越来越大、业务越来越复杂,虽然在用户看来可能是一个 App,但事实上承载了几十上百个业务。以手机淘宝为例,阿里确认“All in 无线”战略后,手机淘宝定位为阿里集团移动端的“航空母舰”,上面承载了非常多的子业务,淘宝的首页第一屏,相关的子业务初步估计就有 10 个以上。
再以微信为例,作为腾讯在移动互联网的“航空母舰”,其业务也是非常的多,“发现”tab 页就有 7 个子业务。
这么多业务集中在一个 App 上,每个业务又在不断地扩展,后续又可能会扩展新的业务,并且每个业务就是一个独立的团队负责开发,因此整个 App 的可扩展性引入了新的复杂度问题。
前面提到可扩展的基本思想就是“拆”,但是这个思想应用到 App 和后端系统时,具体的做法就明显不同了。简单来说,App 和后端系统存在一个本质的区别,App 是面向用户的,后端系统是不面向用户的,因此 App 再怎么拆,对用户还是只能呈现同一个 App,不可能将一个 App 拆分为几十个独立 App;而后端系统就不一样了,采用微服务架构后,后端系统可以拆分为几百上千个子服务都没有问题。同时,App 的业务再怎么拆分,技术栈是一样的,不然没法集成在一个 App 里面;而后端就不同了,不同的微服务可以用不同的技术栈开发。
在这种业务背景下,组件化和容器化架构应运而生,其基本思想都是将超级 App 拆分为众多组件,这些组件遵循预先制定好的规范,独立开发、独立测试、独立上线。如果某个组件依赖其他组件,组件之间通过消息系统进行通信,通过这种方式来实现组件隔离,从而避免各个团队之间的互相依赖和影响,以提升团队开发效率和整个系统的可扩展性。组件化和容器化的架构出现遵循架构设计的“演化原则”,只有当业务复杂度发展到一定规模后才适应,因此我们会看到大厂应用这个架构的比较多,而中小公司的 App,业务没那么复杂,其实并不一定需要采用组件化和容器化架构。
对于组件化和容器化并没有非常严格的定义,我理解两者在规范、拆分、团队协作方面都是一样的,区别在于发布方式,组件化采用的是静态发布,即所有的组件各自独自开发测试,然后跟随 App 的某个版本统一上线;容器化采用的是动态发布,即容器可以动态加载组件,组件准备好了直接发布,容器会动态更新组件,无需等待某个版本才能上线。
关于手机淘宝 App 更详细的架构演进可以参考《Atlas:手淘 Native 容器化框架和思考》,微信 App 的架构演进可以参考 《微信 Android 客户端架构演进之路》。
跨平台 App
前面介绍的各种 App 架构,除了 Web App 外,其他都面临着同一个问题:跨平台需要重复开发。同一个功能和业务,Android 开发一遍,iOS 也要开发一遍,这里其实存在人力投入的问题,违背了架构设计中的“简单原则”。站在企业的角度来讲,当然希望能够减少人力投入成本(虽然我站在程序员的角度来讲是不希望程序员被减少的),因此最近几年各种跨平台方案不断涌现,比较知名的有 Facebook 的 React Native、阿里的 Weex、Google 的 Flutter。虽然也有很多公司在尝试使用,但目前这几个方案都不算很成熟,且在用户体验方面与原生 App 还是有一定差距,例如 Airbnb 就宣布放弃使用 React Native,回归使用原生技术(https://www.oschina.net/news/97276/airbnb-sunsetting-react-native)。
前端的情况也是类似的。
架构实战:架构设计文档模板
备选方案模板
需求介绍
[需求介绍主要描述需求的背景、目标、范围等]
随着前浪微博业务的不断发展,业务上拆分的子系统越来越多,目前系统间的调用都是同步调用,由此带来几个明显的系统问题:
1)性能问题:当用户发布了一条微博后,微博发布子系统需要同步调用“统计子系统”“审核子系统”“奖励子系统”等共 8 个子系统,性能很低。
2)耦合问题:当新增一个子系统时,例如如果要增加“广告子系统”,那么广告子系统需要开发新的接口给微博发布子系统调用。
3)效率问题:每个子系统提供的接口参数和实现都有一些细微的差别,导致每次都需要重新设计接口和联调接口,开发团队和测试团队花费了许多重复工作量。
基于以上背景,需要引入消息队列进行系统解耦,将目前的同步调用改为异步通知。
需求分析
[需求分析主要全方位地描述需求相关的信息]
5W
[5W 指 Who、When、What、Why、Where]
Who:需求利益干系人,包括开发者、使用者、购买者、决策者等。
When:需求使用时间,包括季节、时间、里程碑等。
What:需求的产出是什么,包括系统、数据、文件、开发库、平台等。
Where:需求的应用场景,包括国家、地点、环境等,例如测试平台只会在测试环境使用。
Why:需求需要解决的问题,通常和需求背景相关
消息队列的 5W 分析如下:
Who:消息队列系统主要是业务子系统来使用,子系统发送消息或者接收消息。
When:当子系统需要发送异步通知的时候,需要使用消息队列系统。
What:需要开发消息队列系统。
Where:开发环境、测试环境、生产环境都需要部署。
Why:消息队列系统将子系统解耦,将同步调用改为异步通知。
1H
[这里的 How 不是设计方案也不是架构方案,而是关键业务流程。消息队列系统这部分内容很简单,但有的业务系统 1H 就是具体的用例了,有兴趣的同学可以尝试写写 ATM 机取款的业务流程。如果是复杂的业务系统,这部分也可以独立成“用例文档”]
消息队列有两大核心功能:
1)业务子系统发送消息给消息队列。
2)业务子系统从消息队列获取消息。
8C
[8C 指的是 8 个约束和限制,即 Constraints,包括性能 Performance、成本 Cost、时间 Time、可靠性 Reliability、安全性 Security、合规性 Compliance、技术性 Technology、兼容性 Compatibility]
注:需求中涉及的性能、成本、可靠性等仅仅是利益关联方提出的诉求,不一定准确;如果经过分析有的约束没有必要,或成本太高、难度太大,这些约束是可以调整的。
性能:需要达到 Kafka 的性能水平。
成本:参考 XX 公司的设计方案,不超过 10 台服务器。
时间:期望 3 个月内上线第一个版本,在两个业务尝试使用。
可靠性:按照业务的要求,消息队列系统的可靠性需要达到 99.99%。
安全性:消息队列系统仅在生产环境内网使用,无需考虑网络安全;如消息中有敏感信息,消息发送方需要自行进行加密,消息队列系统本身不考虑通用的加密。
合规性:消息队列系统需要按照公司目前的 DevOps 规范进行开发。
技术性:目前团队主要研发人员是 Java,最好用 Java 开发。
兼容性:之前没有类似系统,无需考虑兼容性。
复杂度分析
[分析需求的复杂度,复杂度常见的有高可用、高性能、可扩展等,具体分析方法请参考专栏前面的内容]
高可用
对于微博子系统来说,如果消息丢了,导致没有审核,然后触犯了国家法律法规,则是非常严重的事情;对于等级子系统来说,如果用户达到相应等级后,系统没有给他奖品和专属服务,则 VIP 用户会很不满意,导致用户流失从而损失收入,虽然也比较关键,但没有审核子系统丢消息那么严重。
综合来看,消息队列需要高可用性,包括消息写入、消息存储、消息读取都需要保证高可用性。
高性能
前浪微博系统用户每天发送 1000 万条微博,那么微博子系统一天会产生 1000 万条消息,平均一条消息有 10 个子系统读取,那么其他子系统读取的消息大约是 1 亿次。将数据按照秒来计算,一天内平均每秒写入消息数为 115 条,每秒读取的消息数是 1150 条;再考虑系统的读写并不是完全平均的,设计的目标应该以峰值来计算。峰值一般取平均值的 3 倍,那么消息队列系统的 TPS 是 345,QPS 是 3450,考虑一定的性能余量。由于现在的基数较低,为了预留一定的系统容量应对后续业务的发展,将设计目标设定为峰值的 4 倍,因此最终的性能要求是:TPS 为 1380,QPS 为 13800。TPS 为 1380 并不高,但 QPS 为 13800 已经比较高了,因此高性能读取是复杂度之一。
可扩展
消息队列的功能很明确,基本无须扩展,因此可扩展性不是这个消息队列的关键复杂度。
备选方案
[备选方案设计,至少 3 个备选方案,每个备选方案需要描述关键的实现,无须描述具体的实现细节。此处省略具体方案描述,详细请参考之前的文章]
备选方案评估
架构设计模板
[备选方案评估后会选择一个方案落地实施,架构设计文档就是用来详细描述细化方案的]
1、总体方案
[总体方案需要从整体上描述方案的结构,其核心内容就是架构图,以及针对架构图的描述,包括模块或者子系统的职责描述、核心流程]
2、架构总览
[架构总览给出架构图以及架构的描述]

架构关键设计点:
1)采用数据分散集群的架构,集群中的服务器进行分组,每个分组存储一部分消息数据。
2)每个分组包含一台主 MySQL 和一台备 MySQL,分组内主备数据复制,分组间数据不同步。
3)正常情况下,分组内的主服务器对外提供消息写入和消息读取服务,备服务器不对外提供服务;主服务器宕机的情况下,备服务器对外提供消息读取的服务。
4)客户端采取轮询的策略写入和读取消息。
3、核心流程
消息发送流程
消息读取流程
4、详细设计
高可用设计
1)消息发送可靠性
业务服务器中嵌入消息队列系统提供的 SDK,SDK 支持轮询发送消息,当某个分组的主服务器无法发送消息时,SDK 挑选下一个分组主服务器重发消息,依次尝试所有主服务器直到发送成功;如果全部主服务器都无法发送,SDK 可以缓存消息,也可以直接丢弃消息,具体策略可以在启动 SDK 的时候通过配置指定。
如果 SDK 缓存了一些消息未发送,此时恰好业务服务器又重启,则所有缓存的消息将永久丢失,这种情况 SDK 不做处理,业务方需要针对某些非常关键的消息自己实现永久存储的功能。
2)消息存储可靠性
消息存储在 MySQL 中,每个分组有一主一备两台 MySQL 服务器,MySQL 服务器之间复制消息以保证消息存储高可用。如果主备间出现复制延迟,恰好此时 MySQL 主服务器宕机导致数据无法恢复,则部分消息会永久丢失,这种情况不做针对性设计,DBA 需要对主备间的复制延迟进行监控,当复制延迟超过 30 秒的时候需要及时告警并进行处理。
3)消息读取可靠性
每个分组有一主一备两台服务器,主服务器支持发送和读取消息,备服务器只支持读取消息,当主服务器正常的时候备服务器不对外提供服务,只有备服务器判断主服务器故障的时候才对外提供消息读取服务。
主备服务器的角色和分组信息通过配置指定,通过 ZooKeeper 进行状态判断和决策。主备服务器启动的时候分别连接到 ZooKeeper,在 /MQ/Server/[group]目录下建立 EPHEMERAL 节点,假设分组名称为 group1,则主服务器节点为 /MQ/Server/group1/master,备服务器的节点为 /MQ/Server/group1/slave。节点的超时时间可以配置,默认为 10 秒。
高性能设计
[此处省略具体设计]
可扩展设计
[此处省略具体设计。如果方案不涉及,可以简单写上“无”,表示设计者有考虑但不需要设计;否则如果完全不写的话,方案评审的时候可能会被认为是遗漏了设计点]
安全设计
消息队列系统需要提供权限控制功能,权限控制包括两部分:身份识别和队列权限控制。
1)身份识别
消息队列系统给业务子系统分配身份标识和接入 key,SDK 首先需要建立连接并进行身份校验,消息队列服务器会中断校验不通过的连接。因此,任何业务子系统如果想接入消息队列系统,都必须首先申请身份标识和接入 key,通过这种方式来防止恶意系统任意接入。
2)队列权限
某些队列信息可能比较敏感,只允许部分子系统发送或者读取,消息队列系统将队列权限保存在配置文件中,当收到发送或者读取消息的请求时,首先需要根据业务子系统的身份标识以及配置的权限信息来判断业务子系统是否有权限,如果没有权限则拒绝服务。
其他设计
[其他设计包括上述以外的其他设计考虑点,例如指定开发语言、符合公司的某些标准等,如果篇幅较长,也可以独立进行描述]
1)消息队列系统需要接入公司已有的运维平台,通过运维平台发布和部署。
2)消息队列系统需要输出日志给公司已有的监控平台,通过监控平台监控消息队列系统的健康状态,包括发送消息的数量、发送消息的大小、积压消息的数量等,详细监控指标在后续设计方案中列出。
部署方案
[部署方案主要包括硬件要求、服务器部署方式、组网方式等]
消息队列系统的服务器和数据库服务器采取混布的方式部署,即:一台服务器上,部署同一分组的主服务器和主 MySQL,或者备服务器和备 MySQL。因为消息队列服务器主要是 CPU 密集型,而 MySQL 是磁盘密集型的,所以两者混布互相影响的几率不大。
硬件的基本要求:32 核 48G 内存 512G SSD 硬盘,考虑到消息队列系统动态扩容的需求不高,且对性能要求较高,因此需要使用物理服务器,不采用虚拟机。
5、架构演进规划
[通常情况下,规划和设计的需求比较完善,但如果一次性全部做完,项目周期可能会很长,因此可以采取分阶段实施,即:第一期做什么、第二期做什么,以此类推]整个消息队列系统分三期实现:
第一期:实现消息发送、权限控制功能,预计时间 3 个月。
第二期:实现消息读取功能,预计时间 1 个月。
第三期:实现主备基于 ZooKeeper 切换的功能,预计时间 2 周。
22.7 - 架构设计06-实践
系统架构图
很多同学技术能力很强,架构设计也做得很好,但是在给别人讲解的时候,总感觉像是“茶壶里煮饺子,有货倒不出”。其实,在为新员工培训系统架构、给领导汇报技术规划、上技术大会做演讲或者向晋升评委介绍工作贡献的时候,如果能画出一张优秀的软件系统架构图,就可以大大提升自己的讲解效果,让对方轻松地理解你想表达的关键点。
4+1 视图
1995 年,Philippe Kruchten 在 论文 中指出了过去用单一视图描述软件系统架构的问题,并提出了 4+1 视图作为解决方案。
有时,软件架构的问题来源于系统设计者过早地划分软件或者过分地强调软件开发的某一个方面,比如数据工程、运行时效率、开发策略或团队组织。此外,软件架构往往不能解决它的所有“用户”的问题。……作为补救措施,我们建议使用几个并发视图来组织对软件架构的描述,其中每个视图分别解决一组特定的问题。
不同视图之间的关系如下图所示:

4+1 视图的核心理念是从不同的角度去剖析系统,看看系统的结构是什么样的,具体每个视图的含义是:
1)逻辑视图:从终端用户角度看系统提供给用户的功能,对应 UML 的 class 和 state diagrams。
2)处理视图:从动态的角度看系统的处理过程,对应 UML 的 sequence 和 activity diagrams。
3)开发视图:从程序员角度看系统的逻辑组成,对应 UML 的 package diagrams。
4)物理视图:从系统工程师角度看系统的物理组成,对应 UML 的 deployment diagrams。
5)场景视图:从用户角度看系统需要实现的需求,对应 UML 的 use case diagrams。
(备注:逻辑视图看到的“功能”和场景视图看到的“需求”不是一回事。一个需求可能涉及多个功能,例如“取款”这个场景涉及“插卡”“密码验证”“出钞”等功能;而多个需求可能涉及同一个功能,例如“取款”和“转账”是两个不同的需求,但是都涉及“密码验证”这个功能。)
可以看到,4+1 视图本身很全面也很规范,但是为什么在实际工作中,真正按照这个标准来画架构图的公司和团队并不多。
原因主要有三点:
1)架构复杂度增加:1995 年的时候,系统大部分还是单体系统,而现在分布式系统越来越多。如果我们用 4+1 视图来表示分布式系统的话,就会遇到困难,比如微服务架构下有那么多的微服务,Development view 就不好表示。
2)绑定 UML 图:UML 图画架构图存在问题,主要问题是不美观,表达能力弱。

(备注:左图是用 UML 工具画的,右图是用 Visio 画的,对比之下,UML 图的缺点十分明显。)
3)理解困难:逻辑视图、开发视图和处理视图比较容易混淆。比如说,有人把逻辑视图理解为软件开发的类结构图,也有人把处理视图和开发视图等同,还有人认为逻辑视图就是开发视图。
这些原因导致 4+1 视图在目前的实际工作中并不是很实用。下面介绍怎么画软件系统架构图。
核心指导思想:4R 架构定义
其实,很多人之所以画不好架构图,最大的痛点就是不好把握到底要画哪些内容,画得太少担心没有展现关键信息,画得太多又觉得把握不住重点。所以现在的问题变成了:应该按照什么样的标准来明确架构图要展现的内容呢?
前面提到的 4R 架构定义:软件架构指软件系统的顶层(Rank)结构,它定义了系统由哪些角色(Role)组成,角色之间的关系(Relation)和运作规则(Rule)。
4R 是指 4 个关键词:Rank,Role,Relation 和 Rule。既然可以通过 4R 来定义软件系统的架构,那么按照 4R 架构定义的思路来画架构图也是很合情合理的,具体步骤如下:
1)明确 Rank:也就是说,不要事无巨细地把一个大系统的方方面面都在一张架构图中展现出来,而应该明确你要阐述的系统所属的级别(L0~L4),然后只描述这个级别的架构信息。
2)画出 Role:从不同的角度来分解系统,看看系统包含哪些角色,角色对应架构图中的区块、图标和节点等。
3)画出 Relation:有了角色后,画出角色之间的关系,对应架构图中角色之间的连接线,不同的连接线可以代表不同的关系。
4)最后画出 Rule:挑选核心场景,画出系统角色之间如何协作来完成某项具体的业务功能,对应系统序列图。
描述 Role 和 Relation 的架构图称为静态架构图,描述 Rule 的系统序列图称为动态架构图。
从某一个角度去看,静态架构图的数量跟系统复杂度有关,一般是 1~2 张,如果比较简单,用一张图就够了,如果比较复杂,就要分别用两张图来展现;而动态架构图是一般是多张,因为核心场景数量不止一个,对应的系统序列图有多张。
常见架构图
从不同的角度去剖析系统,就会得到不同的视图。其实按照 4R 架构定义来画架构图也是这样,用不同的方式去划分系统,就会得到不同类型的架构,分别对应不同类型的架构图。常见的类型整理如下:

接下来讲解每一类架构图的特点。
业务架构图
【定义】描述系统对用户提供了什么业务功能,类似于 4+1 视图的场景视图。
【使用场景】产品人员规划业务:比如说我们经常在产品规划和汇报会议上看到产品人员会用业务架构图来展现业务全局状态。给高 P 汇报业务:对于 P7+ 以上级别的技术人员,在汇报的时候不能光讲技术,也要讲业务的发展情况,用业务架构图就比较容易的展现业务整体情况。给新员工培训业务。
【画图技巧】
1)通过不同颜色来标识业务状态:比如说哪些业务发展状态好,哪些问题比较多,哪些比较稳定,哪些竞争比较激烈等。
2)业务分组管理:将类似的业务放在一个分组里面展现,用虚线框或者相同背景将其标识出来。
3)区块对齐:为了美观,可以改变不同区块的长短大小进行对齐,让整体看起来更美观。
【参考案例】AlipayHK 的一个业务架构图如下所示:

这张业务架构图有三点关键信息:
1)“MTR”区块是浅红色的,“人传人”区块是绿色的,浅红色代表正在进行的,绿色代表明年规划的。
2)分了 4 组:钱包业务、第三方业务、商家服务和用户管理。
3)“转账”和“社交红包”等区块比较长,只是为了对齐后更美观,不代表业务本身的量级或者重要程度,如果要表示这样的信息,那么可以用颜色来表示。
注意,千万不要画得五颜六色,一般一张图的颜色数量控制在 3 种以内是比较好的。所以在画图的时候你要想清楚,到底哪些信息是要放在业务架构图中重点展示的关键信息,哪些信息顺带讲一下就可以了。
客户端和前端架构图
【定义】描述客户端和前端的领域逻辑架构,关注的是从逻辑的角度如何分解客户端或者前端应用。
【使用场景】
1)整体架构设计:由客户端或者前端架构师完成本领域的架构设计。
2)架构培训。
【画图技巧】
1)通过不同颜色来标识不同角色。
2)通过连接线来表示关系,如果有多种关系,例如有的是直接调用,有的是事件通知,那么可以用不同形状的线条来表示。
3)分层或分组:将类似的角色分层或者分组管理。
【参考案例】微信客户端架构 3.x 的架构图如下所示:

这张客户端架构图有三点关键信息:
1)图中用了灰色(app:UI 等)、蓝色(Net Scene 等)、深灰色(Storage)、浅蓝色(Network)来表示不同类型的模块。
2)图中有两类连接线:双向的(WebViewUI 和 app:UI),单向的(app:UI 和 Net Scene 等)。
3)整体上分为 4 组,对应图中背景色不同的四个大的区块。
系统架构图
【定义】描述后端的逻辑架构,又叫“后端架构”或“技术架构”,不管是业务系统、中间件系统,还是基础的操作系统、数据库系统等,系统架构都是软件系统架构的核心。
【使用场景】整体架构设计。架构培训。
【画图技巧】通过不同颜色来标识不同角色。通过连接线来表示关系。逻辑分组。
【参考案例】如果系统比较简单,可以参考 MongoDB Sharding 的系统架构图,如下所示:

如果系统相对复杂,建议首先用一张图来展示系统架构里面的角色(Role)以及每个角色的核心功能;然后再用一张图来展示角色之间的关系(Relation),可以参考一个支付中台的系统架构图,如下所示:


(备注:完整的支付中台关系图太大了,这张关系图只是摘取其中一部分作为示意图,供你参考。)
应用架构图
【定义】描述后端系统由哪些应用组成,一个应用就是一个可部署发布运行的程序,它是项目开发过程中,开发测试运维团队协作的基础。
【使用场景】项目开发、测试。运维部署发布。子域架构设计。
【画图技巧】通过不同颜色来标识不同角色。通过连接线来表示关系。复杂系统分域来画。
【参考案例】如果系统比较简单,那么基本上应用架构和系统架构是等价的,可以参考 MongoDB Sharding 的应用架构图,如下所示:

可以看到,这张图中的 Router(mongos)、Config Servers 和 Shard(replica set),既包含了系统架构的角色信息(Router、Config Servers 和 Shard),又包含了应用信息(mongos、Config Servers 和 Shard)。
如果系统比较复杂,按照架构分层的角度来看,应用架构已经到了可执行程序这一层,例如支付中台这一类的系统,包含的应用可能有几百上千个,如果把整个支付中台所有的应用都在一张图里面展示出来,信息太多太密,可能会导致架构图都看不清。
这种情况下,应用架构一般都是按照子域来画应用架构图,可以参考支付中台的会员域的应用架构图,如下所示:

部署架构图
【定义】描述后端系统具体是如何部署的,主要包含机房信息、网络信息和硬件信息等。
【使用场景】总体架构设计。运维规划和优化。
【画图技巧】用图标代替区块,这样看起来更加美观和容易理解。
【参考案例】一个简单的支付系统的部署架构图如下所示:

系统序列图
【定义】描述某个业务场景下,系统各个角色如何配合起来完成业务功能。
【使用场景】结合“系统架构、应用架构和部署架构”来使用。
【画图技巧】使用 UML 的序列图来画。
【参考案例】“扫码支付”这个支付核心场景的系统序列图如下所示:

(备注:这张序列图的角色对应前面“系统架构”这一小节的支付中台系统的关系图。)
补充说明
如果你曾经研究过架构图的标准,那么除了 4+1 视图以外,你可能还看到过 TOGAF 的“业务架构(跟这一讲的业务架构名字相同,但是意义不同)、数据架构(不是指大数据平台架构,而是指数据资产的架构)、应用架构和技术架构”这种说法,或者还看到过 C4 架构模型(Context、Container、Component 和 Code)等等。
但其实目前业界并没有就架构图标准达成共识,刚才提到的 TOGAF 是企业级的架构,基本上要到 CTO 这个级别才能接触的,而 C4 模型的表达能力又不够。这里并没有直接套用这些内容,而是根据个人经验,将认为最有效果的架构图整理出来。这些架构图,都是在不同类型不同规模不同业务的公司(华为、UC、阿里和蚂蚁等)里面验证过的,可以放心地使用。
23 - 读书笔记
Introduction
这是我的读书笔记
23.1 - 大话设计模式
2019-11-05
第一章:简单工厂模式
加减乘除实现计算接口,计算接口有两个属性,一个计算方法。工厂类case判断计算类型,实例化出具体的实现类。
UML类图
1、矩形代表一个类,类图分三层,第一层:类的名称(抽象类使用斜体);第二层:类的特性(字段和属性);第三层:类的操作(方法或行为)。“+”表示public,“-”表示private,“#”表示protected。
2、接口,与类的区别主要是顶端有<
3、继承关系,空心三角形+实线。
4、实现接口关系,空心三角形+虚线。
5、关联关系,实线箭头。当一个类“知道”另一个类时,可以用关联(association)。如“企鹅”关联“气候”。
6、聚合关系,空心的菱形+实线箭头。表示一种弱的“拥有”关系,提现的是A对象可以包含B对象,但B对象不是A对象的一部分,如“雁群”与“大雁”。
7、合成(组合)关系,实心的菱形+实线箭头。合成(composition)是一种强的“拥有”关系,体现了严格的部分和整体的关系,部分和整体的生命周期一样。如“鸟”与“翅膀”。
8、依赖关系,虚线箭头。如“动物”与“氧气”、“动物”与“水”。
第二章:商场促销——策略模式
“面向对象的编程,并不是类越多越好,类的划分是为了封装,但分类的基础是抽象,具有相同属性和功能的对象的抽象集合才是类”——不要滥用子类!
策略模式——将不同类型算法分别封装起来,可以互相替换,此模式让算法的变化,不会影响到使用算法的客户。
Context,维护对Strategy对象的引用。抽象算法类:正常算法A;打折算法B;满减算法B。
| |
简单工厂需要让客户端认识两个类CashSuper和CashFactory,而策略模式与简单工厂结合的用法,客户端只需要认识一个雷CashContext就可以。耦合更加降低。 优点:简化了单元测试。
2019-11-06
第三章:拍摄UFO——单一职责原则
第四章:考研求职两不误——开放-封闭原则
开放-封闭原则:软件实体(类、模块、函数等等)应该可以扩展,但是不可修改。
第五章:会修电脑不会修收音机?——依赖倒转原则
依赖倒转原则:
A. 高层模块不应该依赖底层模块
B. 抽象不应该依赖细节。细节应该依赖抽象。
里氏代换原则——子类型必须能够替换掉它们的父类型。
“只有当子类可以替换掉父类,软件单位的功能不受到影响时,父类才能真正被复用,而子类也能够在父类的基础上增加新的行为。”
第六章:穿什么有这么重要?——装饰模式
装饰模式——动态地给一个对象添加一些额外的职责,就增加功能来说,装饰模式比生成子类更加灵活。
第七章:为别人做嫁衣——代理模式
第八章:雷锋依然在人间——工厂方法模式
工厂方法模式(Factory Method)——定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。
简单工厂违背了“开放——封闭原则”
| |
2019-11-08
第九章:简历复印——原型模式
浅复制与深复制
第十章:考题抄错会做也白搭——模板方法模式
模板方法——当我们要完成在某一细节层次一致的一个过程或一系列步骤,但其个别步骤在更详细的层次上的实现可能不同时,我们通常考虑用模板方法模式来处理。
第十一章:无熟人难办事?——迪米特法则
迪米特法则——也叫最少知识原则。如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中一个类需要调用另一个类的某个方法的话,可以通过第三者转发这个调用。
第十二章:牛市股票还会亏钱?——外观模式(Facade)
外观模式——为子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
第十三章:好菜每回味不同——建造者模式
第十四章:老板回来,我不知道——观察者模式
第十五章:就不能不换DB吗?——抽象工厂模式
抽象工厂模式(Abstract Factory)——提供一个创建一系列相关或互相依赖对象的接口,而无需指定它们具体的类。
抽象工厂需要更改的工厂类太多,简单工厂switch case太多,反射加抽象工厂的数据访问程序。
第十六章:无尽加班何时休——状态模式
状态模式(State)——当一个对象的内在状态改变时允许改变其行为,这个对象看起来像是改变了其类。
“状态模式主要解决的是当控制一个对象状态转换的条件表达式过于复杂时的情况。把状态的判断逻辑转移到表示不同状态的一系列类当中,可以把复杂的判断逻辑简化。”
状态模式好处与用处
“装填模式的好处是将与特定状态相关的行为局部化,并且将不同状态的行为分割开来。”
“将特定状态相关的行为都放入一个对象中,由于所有与状态相关的代码都存在于某个ConcreteState中,所以通过定义新的子类可以很容易地增加新的状态和转换。”
“状态模式通过把各种状态转移逻辑分布到State的子类之间,来减少相互间的依赖。”
“当一个对象的行为取决于他的状态,并且它必须在运行时刻根据状态改变它的行为时,就可以考虑使用状态模式了。”
第十七章:在NBA中我需要翻译——适配器模式
适配器模式——将一个类的接口转换成客户希望的另外一个接口。Adapter模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
第十八章:备忘录模式
备忘录(Memento)——在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
第十九章:分公司=一部门——组合模式
第二十章:想走?可以!先买票——迭代器模式
迭代器模式(Iterator)——提供一种方法顺序访问一个聚合对象中各个元素,而又不暴露该对象的内部表示。
第二十一章:有些类也需计划生育——单例模式
第二十二章:手机软件何时统一——桥接模式
合成/聚合复用原则——尽量使用合成/聚合,尽量不要使用类继承。
桥接模式——将抽象部分与它的实现部分分离,使它们都可以独立地变化。
“手机品牌”与“手机软件”是弱的聚合关系,像一座桥。
第二十三章:烤羊肉串引来的思考——命令模式
第二十四章:加薪非要老总批?——职责链模式
职责链模式——使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。将这个对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
第二十五章:世界需要和平——中介者模式
中介者模式(Mediator)——用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
第二十六章:项目多也别傻做——享元模式
享元模式(Flyweight)——运用共享技术有效地支持大量细粒度的对象。
第二十七章:其实你不懂老板的心——解释器模式
解释器模式——给定一个语言,定义它的文法的一种表示,并定义一个解释器,这个解释器使用该表示来解释语言中的句子。
第二十八章:男人和女人——访问者模式
访问者模式(Visitor)——表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
23.2 - 代码整洁之道
命名技巧
某团队对于repository层的约定
插入:add() addAll()
更新:save() saveAll()
删除:remove() removeAll()
聚合根查询:find() findAll()
关键词检索:searchXX()
命名技巧
方法名
动词开头:如createXXX()
避免混用:query/fetch/find/search
规避特殊词:一般方法避免get/set开头
变量
1、尽量避免使用单字母或缩写
即使在for循环中:pos/index也比i/j/k要优雅
2、变量名长度适中
maxAudienceNum
limitOfAudienceNumInstatium //舒适区
3、用词技巧
1、布尔变量
status //bad
isDone //good
2、静态变量
flag //bad
dataReady //good
3、常量
int VERSION = 410
4、集合变量
List
23.3 - 对伪心理学说不
2019-10-20
理论的可证伪性原则,全能理论永远不能被证伪,证伪具有解放意义。
如果一个理论不可证伪,并且和自然界的真实事件没有关联,那么它就是无用的。
操作主义和本质主义
科学的独特优势并不在于是一个不会犯错的过程,而在于它提供了一种消除错误的方式,它能不断消除我们认识中的错误。
本质主义者喜欢咬文嚼字。在科学领域里,确定某概念的意义,是在与该术语有关的现象得到一定程度的研究之后,而非研究之前。一个精确的概念性术语来自科学过程中固有的那种数据和理论间的相互作用,而不是关于语言用法的辩论。本质主义者让我们陷入无休止的文字争论,而许多科学家坚信这样的文字游戏使我们脱离了事物的实质。总之,科学家的目的是解释现象,而非对措词进行分析。在所有的科学学科里,进步的关键在于放弃本质主义,接受操作主义。
信度和效度,鞋码器测智力只有信度没有效度,需要同时兼顾。
心理学所面临的一个难题就是,公众要求心理学去回答本质主义问题,而通常其他科学家并不需要回答类似的问题。如地心引力vs智力。心理学和其他科学门类一样,正在试图不断地完善其操作性定义,使理论概念能够更加准确地反映真实世界的原貌。
个例与见证
过于依赖见证证据的问题一直存在。此类证据的鲜活性常常掩盖了更加可靠的信息,并且混淆视听。
巴纳姆效应
大多数成年人都会认为泛化的个性总结都是准确的,并且都是对自己独特的描述。
选择性偏差
维纳讲了一个二战期间的故事,这个故事提醒我们选择性偏差违背常理的一面。他提到一位飞机分析师,这个分析师一直试图通过分析飞机被子弹击中的弹孔分布,来确定飞机上的哪个部门是应该放置加固防弹层的位置。他最后的决定是:把加固防弹层放在返航机上没有弹孔的地方。他的理由是,子弹袭击飞机各个部位的几率是均等的,所以,如果一架飞机能返回,就表示这架飞机被子弹击中的地方必定不会对飞机造成致命损伤。那些没有弹孔的地方,看来都是要害,因为该部位如果被击中,飞机可能就不会返航。因此加固防弹层应该安装在返航机没有被击中的部位!
提防选择性偏差的发生,当只有相关时,应避免因果推论。
武器效应——如果一件武器出现在手边,会使得某个人更容易做出攻击性反应。这个发现源于实验室,是一个无代表性情境的典型例子。由于这一结果是人为情境的误导产物,因此常被强烈地批评其具有误导性。但事实是这样的,各种实验条件下得出的结果都一样,用不同的方法测量攻击性所得的结果一样,在欧洲和没过获得的结果一样,研究儿童和成人的结果一样,在实验室之外的现场研究中,被试不知道自己在参与实验,得出的结果也一样。
避免爱因斯坦综合征
两个原则——关联性原则和聚合性证据原则
伪科学家的两种伎俩:一、解释数据前先将这个理论变得不可证伪,这样就令先前的数据毫无用处;二是宣称先前的数据与他们的主题无关,因而不予考虑。为了实现"不予考虑”的结果,他们通常强调新理论呈现出“前所未有”的新颖性。类似“关于现实的全新观念”和“前所未有”这样的语句被频频使用。
遵循关联性原则!!“科学家不能仅仅依靠一个人的观点,而是要依靠千万人的智慧”用一个简单原理来总结:在评估心理学的实证证据时,心中要想的是“科学共识”,而不是“重大突破”;是“渐进整合”,而不是“大步飞跃”。不要对矛盾数据感到绝望,证据融合过程就像幻灯片调焦过程,或许支持了多重假设。
“跃进”模式对于心理学来说是一种糟糕的模式。聚合性证据原则描述了心理学上研究结果是如何被整合的:没有一个实验室可以一锤定音的,但是每一个实验至少都能帮助我们排除一些可能的解释,并让我们在接近真理的道路上向前迈进。
24 - 生活常识
Introduction
生活常识
24.1 - 急救知识
相关名词
CPR:Cardio Pulmonary Resuscitation,心肺复苏术
AED:自动体外除颤器又称自动体外电击器、自动电击器、自动除颤器、心脏除颤器及傻瓜电击器等。
检查&寻求帮助
- 确认现场安全(双手摊开)、做好个人防护(手套或防护服等)。 (表明身份)我学过急救我可以为您提供帮助;
- (双膝跪在一侧)拍打双肩并询问如“您还好吗”,检查是否有反应;
- 指定人(如某某颜色衣服的人,不要泛指)打120,拿AED;
- 数7秒钟(4个音节为1秒,可以数“一千零一、一千零二”等)观察胸腹部是否有起伏。
不要观察鼻子是否呼吸,户外或者嘈杂环境很难判断
如没反应开始心肺复苏
心肺复苏(CPR)
一般30次胸部按压 + 2次人工呼吸(10秒内)
胸部按压
原理
胸骨按压带动肋骨心脏血流到大脑和其他主要器官,延续其细胞存活时间、延缓死亡。胸骨相比肋骨弹性更大,不容易断。
口诀
用力压、快快压、快回弹、莫中断。
按压频率
1分钟为100-120次按压,约为1秒2次(4个音节为1秒,可以01、02数数)。
按压位置
男士一般两个乳头中间,女士或其他人群可以用腋下或者锁骨到下胸骨交叉点的中间位置。
按压姿势
- 双手掌同向交叉,下手掌伸直,上手掌抓住下手掌
- 两只手臂伸直,使用整个身体的力量而不是双臂
- 使用下手掌掌根对准按压位置
按压深度
- 成年人一般5厘米以上(使劲按不要怕)
- 儿童(1岁-青春期)至少为胸廓前后径的三分之一,约为5厘米
- 婴儿(1岁以下)至少为胸廓前后径的三分之一,约为4厘米
注意事项
- 让胸部回弹:每次按压后,让胸部恢复到正常位置
- 尽量减少胸外按压过程中断:即使人工呼吸时中断按压也不要超过10秒
- 婴儿按压:用单手双指或双手大拇指
人工呼吸
打开气道
单手提起下颌,注意手的姿势,一般使用一指或两指即可,不要有掐脖子动作。
成人 另一只手捏住鼻子或用力压住面罩。
婴儿 用自己的嘴包住婴儿嘴鼻。
吹气
人呼出的气不仅包括二氧化碳,还包括氧气和各种气体混合物。
目的:往患者肺部补充氧气。
动作:吸一口气然后直接吹气即可。
观察:患者胸部是否有拱起。
注意:只吹2次且不要超过10秒,如果吹气失败也要终止继续胸部按压。
AED
作用
自动检查心率情况,必要时采取电击方式帮助患者恢复心律。
使用时间
AED到达现场第一时间内。
使用步骤
- 开启电源
- 电极片电线连接主机
- 粘贴电极片在患者皮肤上(若有水需擦除,毛发太多则去毛)
电极片位置
- 2个电极片则贴在左上(胸部与肩部之间)和右下(腹部侧边)。
- 1个电极片或儿童则只贴在左上位置。
AED儿童模式
如果是儿童注意开启AED儿童模式,一般是长按儿童对应按钮3秒。
驱离人员
大声驱逐患者接触人员
- AED执行分析患者心律时(防止他人接触误分析)
- 按下电击按钮时(防止击伤他人、患者电击功率不够)(一般是2000V以上几十A)
使用原则
按照AED提示即可,按照使用步骤完成后会自动提示分析患者心律或执行电击。
窒息
成人窒息
一手握拳另一只手握住,背后抱住用力击打腹腔,孕妇或者体型过大则换成胸腔。
婴儿窒息
正面两指按压5次+背面拍打肩胛骨5次(左右手托住头颈+左右腿向下倾斜),循环往复直至恢复或失去反应(继续进行心肺复苏)。
注意事项:
- 用力:是亲生的就用力。
溺水
清除口腔内的异物并采取头低俯卧位行体位引流,用力拍打背部排出气管内的液体。然后做人工呼吸,无反应则进行心肺复苏。
注意事项:
- 不要管积水,一般积水主要在胃部,肺部不会有太多积水。
烫伤/烧伤
凉水一直冲直至皮肤温度降低到正常。
注意事项:
- 不要用冰块,会导致毛细血管收缩热量无法散出
- 也不要涂任何无用药物,会增加二次清创难度
中暑
将患者防止阴凉通风处,喝电解水,想尽方法降温。
